diff --git a/CLAUDE.md b/CLAUDE.md index 114866bc8f8d..a4d16e9fda26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,9 +227,11 @@ Project ownership is distributed across teams. Check individual project director 1. **Layered Architecture with Dependency Inversion** - Core defines contracts (interfaces) - - Infrastructure implements contracts + - Infrastructure implements contracts that need Infrastructure-owned machinery - Web/APIs consume implementations via DI + **Where service implementations live**: Services whose dependencies are satisfiable from Core interfaces alone (repositories, scope, config, other Core services) live in `Umbraco.Core/Services/` — this covers the majority of domain services (`MemberService`, `ContentService`, `MediaService`, `ContentTypeService`, `EntityService`, `AuditService`, `ExternalMemberService`, etc.). Service implementations only live in `Umbraco.Infrastructure/Services/Implement/` when they genuinely need Infrastructure concerns — Examine indexes (`ContentSearchService`, `MediaSearchService`, `IndexedEntitySearchService`), log files (`LogViewerRepository`), packaging internals (`PackagingService`), webhook firing (`WebhookFiringService`), distributed-job coordination (`DistributedJobService`). When adding a new service, default to Core and only move to Infrastructure if a concrete dependency forces it. + 2. **Interface-First Design** - All services defined as interfaces in Core - Enables testing, polymorphism, extensibility diff --git a/research-load-balanced-distributed-jobs.md b/research-load-balanced-distributed-jobs.md new file mode 100644 index 000000000000..d3679a6849f7 --- /dev/null +++ b/research-load-balanced-distributed-jobs.md @@ -0,0 +1,244 @@ +# Research: IDistributedBackgroundJob Write Lock Timeout in Load-Balanced Setup + +**Issue**: [#22113](https://github.com/umbraco/Umbraco-CMS/issues/22113) +**Error**: `Failed to acquire write lock for id: -347` +**Lock -347**: `Constants.Locks.DistributedJobs` (all distributed background jobs) + +--- + +## Summary + +The root cause is most likely **SQL Server page-level lock contention** on the `umbracoLock` table, caused by long-running content operations (inside the user's distributed job) holding REPEATABLEREAD locks on one row (e.g., `-333` ContentTree) which block write access to *all other rows on the same data page* (including `-347` DistributedJobs). + +This is exacerbated by: +1. **Nested scope transaction sharing** - the user's outer scope holds the transaction (and all locks) open for the entire job duration +2. **Small table, single page** - all ~18 lock rows fit on one 8KB SQL Server data page +3. **5-second write lock timeout** - the default is too short when contention exists +4. **Backoffice activity** adding further lock pressure on the same table + +--- + +## Detailed Analysis + +### The Lock Table Problem + +The `umbracoLock` table has approximately 18 rows (IDs -331 through -348). In SQL Server, a standard data page is 8KB. These 18 small rows (each just `id INT`, `name NVARCHAR`, `value INT`) **all fit on a single data page**. + +SQL Server's lock granularity decisions: +- For small tables, the query optimizer may choose **page-level locks** instead of row-level locks +- The `WITH (REPEATABLEREAD)` table hint in the locking SQL means locks are held until the **end of the transaction** +- Without an explicit `ROWLOCK` hint, SQL Server decides the granularity + +**Read lock SQL** (from `SqlServerDistributedLockingMechanism.cs:147`): +```sql +SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id +``` + +**Write lock SQL** (from `SqlServerDistributedLockingMechanism.cs:182-183`): +```sql +UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id +``` + +Neither uses a `ROWLOCK` hint, so SQL Server is free to use page-level locking. + +### The Reproduction Scenario + +Here's the exact sequence that causes the error: + +**Server A** (running the user's distributed job): + +1. `DistributedBackgroundJobHostedService` calls `TryTakeRunnableAsync()` +2. `TryTakeRunnableAsync` acquires `EagerWriteLock(-347)`, marks the "Clean Up Your Room" job as running, commits scope, **releases lock -347** -- this is fine +3. The user's `ExecuteAsync()` runs: + ```csharp + using ICoreScope scope = _scopeProvider.CreateCoreScope(); // ROOT scope, starts transaction + + _contentService.CountChildren(...) // Creates NESTED scope, acquires ReadLock(-333) + _contentService.RecycleBinSmells() // Creates NESTED scope, acquires ReadLock(-333) + _contentService.EmptyRecycleBin(...) // Creates NESTED scope, acquires WriteLock(-333) + + scope.Complete(); // Transaction commits HERE, all locks released HERE + ``` + +4. **Critical**: All nested scopes share the root scope's database/transaction (confirmed in `Scope.cs:350-360`). The `ReadLock(-333)` acquired by `CountChildren` is held until the ROOT scope disposes. If `EmptyRecycleBin` takes 30+ seconds (many items), the locks on row -333 are held for 30+ seconds. + +5. With page-level locking, the shared (S) lock on row -333's **page** also covers row -347. This S lock blocks any exclusive (X) lock requests on the same page. + +**Server B** (polling for jobs every 5 seconds): + +6. `TryTakeRunnableAsync()` tries `EagerWriteLock(-347)`: + ```sql + SET LOCK_TIMEOUT 5000; + UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=-347 + ``` +7. This UPDATE needs an exclusive (X) lock on row -347. But the page containing -347 has a shared (S) lock held by Server A's long-running transaction. +8. Server B **blocks for 5 seconds**, then gets SQL error 1222 (lock timeout) +9. This becomes: `DistributedWriteLockTimeoutException` → **"Failed to acquire write lock for id: -347"** + +### Why Backoffice Login Triggers It + +When users log into the backoffice and interact with content: + +- **Listing content**: `ContentService.GetById/GetChildren` → `ReadLock(-333)` +- **Saving content**: `ContentService.Save` → `WriteLock(-333)` +- **Deleting content**: `ContentService.Delete/MoveToRecycleBin` → `WriteLock(-333)` +- **Publishing**: `ContentService.Publish` → `WriteLock(-333)` + +Each of these acquires locks on the `umbracoLock` table. In load-balanced setups, backoffice web requests on *any server* add page-level lock contention on the same data page as -347. The more backoffice activity, the higher the probability that some transaction is holding a page lock that blocks -347 acquisition. + +### Why It "Disables the Server Until Restart" + +The `DistributedBackgroundJobHostedService` catches exceptions and continues (line 80). However: + +1. Every 5 seconds, `TryTakeRunnableAsync` fails with the lock timeout +2. The error is logged each time, creating a flood of error logs +3. **No distributed jobs run on the affected server** because `TryTakeRunnableAsync` always times out +4. The user's custom job that's causing the contention (on the other server) eventually finishes, but by then the pattern of contention from backoffice operations may sustain the problem +5. The server appears "disabled" because its distributed job processing is effectively blocked + +The server doesn't truly need a restart to recover, but the sustained contention from backoffice operations can make it *appear* permanently broken. A restart clears all in-flight transactions and ambient scopes, resolving the immediate contention. + +--- + +## Contributing Factors + +### 1. No `ROWLOCK` Hint + +The distributed locking SQL uses `WITH (REPEATABLEREAD)` but not `WITH (ROWLOCK, REPEATABLEREAD)`. Adding `ROWLOCK` would force SQL Server to use row-level locks, preventing cross-row contention on the same page. + +**File**: `src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerDistributedLockingMechanism.cs` +- Line 147 (read lock): `SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id` +- Line 182-183 (write lock): `UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=@id` + +### 2. Short Default Write Lock Timeout + +**File**: `src/Umbraco.Core/Configuration/Models/GlobalSettings.cs` + +The default write lock timeout is **5 seconds** (`DistributedLockingWriteLockDefaultTimeout`). In a load-balanced setup with active backoffice use, this is easily exceeded during page-level lock contention. + +### 3. User's Outer Scope Prolongs Lock Duration + +The user's code wraps multiple ContentService calls in a single scope: + +```csharp +using ICoreScope scope = _scopeProvider.CreateCoreScope(); +_contentService.CountChildren(...); // ReadLock(-333) acquired, held by root transaction +_contentService.RecycleBinSmells(); // ReadLock(-333) +_contentService.EmptyRecycleBin(...); // WriteLock(-333), potentially slow +scope.Complete(); // ALL locks released here +``` + +The nested scopes created by ContentService methods all share the root scope's transaction (`Scope.cs:350-360`). This means the ReadLock from `CountChildren` is held for the entire duration of `EmptyRecycleBin`. + +### 4. `Task.Run` in User Code + +The user wraps their code in `Task.Run()`: +```csharp +public Task ExecuteAsync() +{ + return Task.Run(() => { ... }); +} +``` + +While this doesn't directly cause the lock issue, `Task.Run` moves execution to a thread pool thread. This is unnecessary (the hosted service already runs on a background thread) and could cause issues with scope ambient context if the async context doesn't flow properly. + +--- + +## Potential Fixes + +### Fix 1: Add `ROWLOCK` Hint (Framework Fix - Recommended) + +Add `ROWLOCK` to the SQL statements in `SqlServerDistributedLockingMechanism`: + +```sql +-- Read lock +SELECT value FROM umbracoLock WITH (ROWLOCK, REPEATABLEREAD) WHERE id=@id + +-- Write lock +UPDATE umbracoLock WITH (ROWLOCK, REPEATABLEREAD) SET value = ... WHERE id=@id +``` + +This forces SQL Server to use row-level locks, preventing cross-row contention within the same page. Row-level locks on id=-333 would NOT block row-level locks on id=-347. + +**Impact**: Minimal. Row-level locks are slightly more expensive in memory (lock manager overhead) but the umbracoLock table is tiny. This is the standard best practice for small lookup tables where row independence is required. + +The same fix should also be applied to the EF Core SQL Server locking mechanism: +- `src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs` + +### Fix 2: Separate Lock Tables (Framework Fix - More Invasive) + +Move distributed job locks to a separate table (`umbracoDistributedJobLock`) so they can never share a page with content tree locks. This is more invasive but eliminates the problem entirely regardless of SQL Server lock granularity decisions. + +### Fix 3: Increase Write Lock Timeout (User Workaround) + +```json +{ + "Umbraco": { + "CMS": { + "Global": { + "DistributedLockingWriteLockDefaultTimeout": "00:00:30" + } + } + } +} +``` + +Increasing to 30 seconds gives more time for the contending transaction to complete. This is a workaround, not a fix - it trades timeout frequency for longer blocking delays. + +### Fix 4: User Code Improvement (User Workaround) + +The user should avoid wrapping multiple ContentService calls in a single outer scope. Each ContentService method already manages its own scope: + +```csharp +public Task ExecuteAsync() +{ + // NO outer scope needed - each ContentService method creates its own scope + int numberOfThingsInBin = _contentService.CountChildren(Constants.System.RecycleBinContent); + _logger.LogInformation("You have {Count} items to clean", numberOfThingsInBin); + + if (_contentService.RecycleBinSmells()) + { + _contentService.EmptyRecycleBin(userId: -1); + } + + return Task.CompletedTask; +} +``` + +This reduces lock hold duration because each ContentService call acquires and releases its locks independently. The `CountChildren` ReadLock(-333) is released before `EmptyRecycleBin` starts. + +Also: remove the `Task.Run` wrapper - it's unnecessary since the hosted service already runs on a background thread. + +--- + +## Key Code References + +| File | Purpose | +|------|---------| +| `src/Umbraco.Infrastructure/BackgroundJobs/DistributedBackgroundJobHostedService.cs` | Timer loop, calls TryTake → Execute → Finish | +| `src/Umbraco.Infrastructure/Services/Implement/DistributedJobService.cs` | Acquires WriteLock(-347) in TryTakeRunnableAsync (line 68) and FinishAsync (line 105) | +| `src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerDistributedLockingMechanism.cs` | SQL Server lock SQL (lines 147, 182-183) - missing ROWLOCK hint | +| `src/Umbraco.Core/Persistence/Constants-Locks.cs` | Lock ID definitions (-331 through -348) | +| `src/Umbraco.Infrastructure/Scoping/Scope.cs:350-360` | Nested scopes share parent's Database/transaction | +| `src/Umbraco.Core/Services/ContentService.cs` | EmptyRecycleBin acquires WriteLock(-333), CountChildren/RecycleBinSmells acquire ReadLock(-333) | +| `src/Umbraco.Core/Configuration/Models/GlobalSettings.cs` | Default lock timeout: 5 seconds for writes | + +--- + +## Verification Steps + +To confirm this hypothesis: + +1. **SQL Server Activity Monitor**: During reproduction, check for page-level locks on the `umbracoLock` table using `sys.dm_tran_locks`: + ```sql + SELECT * FROM sys.dm_tran_locks + WHERE resource_database_id = DB_ID() + AND resource_associated_entity_id = OBJECT_ID('umbracoLock') + ORDER BY request_mode, resource_type + ``` + +2. **Check lock granularity**: Look for `resource_type = 'PAGE'` entries, which would confirm page-level locking. + +3. **Test with ROWLOCK**: Temporarily modify the SQL to include `ROWLOCK` hint and verify the issue disappears. + +4. **Test without outer scope**: Have the user remove the wrapping `CreateCoreScope()` call and verify the issue is mitigated (shorter individual lock durations). diff --git a/research-memory-leaks.md b/research-memory-leaks.md new file mode 100644 index 000000000000..7878747aabfe --- /dev/null +++ b/research-memory-leaks.md @@ -0,0 +1,271 @@ +# Memory Leak Analysis — Umbraco CMS v17 + +**Date**: 2026-03-03 +**Branch**: `main` +**Scope**: All production projects under `src/` +**Methodology**: Static analysis — grep-based pattern matching across ~1,000 C# source files + +--- + +## Executive Summary + +Seven potential memory management issues were identified. None represent an unbounded memory growth path that would cause noticeable degradation or an `OutOfMemoryException` on a typical site running for days or weeks. The most accurate characterisation of the meaningful findings is **reduced `ArrayPool` efficiency** rather than classical memory leaks — the GC reclaims all affected memory eventually, but pooled buffers are not returned promptly. + +The single highest-value fix is a one-line addition to `DatabaseServerMessenger.Dispose()`. Two findings around `JsonDocument` disposal are worth addressing for correctness, particularly on multi-server deployments. The remaining findings have negligible practical impact. + +--- + +## Findings + +### Finding 1 — `CancellationTokenSource` Not Disposed + +| | | +|---|---| +| **File** | `src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs` | +| **Lines** | 24 (creation), 339–349 (Dispose) | +| **Confidence** | High | +| **Practical Impact** | Negligible | + +`DatabaseServerMessenger` implements `IDisposable`, but its `Dispose(bool)` method omits disposal of `_cancellationTokenSource`: + +```csharp +// Line 24 — created +private readonly CancellationTokenSource _cancellationTokenSource = new(); + +// Lines 339–349 — _syncIdle is disposed; _cancellationTokenSource is not +protected virtual void Dispose(bool disposing) +{ + if (!_disposedValue) + { + if (disposing) + { + _syncIdle.Dispose(); + // ← _cancellationTokenSource.Dispose() is missing + } + _disposedValue = true; + } +} +``` + +`CancellationTokenSource` internally holds a native `SafeWaitHandle` (a Win32 event object) that should be released via `Dispose()`. Because this class is a singleton, exactly **one** handle is leaked for the lifetime of the process — the GC finaliser will never reclaim it. The practical memory cost is a few hundred bytes and one OS handle, which is immeasurable in a normal server process. + +**Real-world impact over several days**: None observable. This is a correctness issue rather than a practical one. + +**Recommended fix**: Add `_cancellationTokenSource.Dispose();` inside the `if (disposing)` block at line 345. This is a single-line change. + +--- + +### Finding 2 — `JsonDocument` Not Disposed in Cache Sync Loop + +| | | +|---|---| +| **File** | `src/Umbraco.Infrastructure/Services/CacheInstructionService.cs` | +| **Lines** | 287, 293, 315–334 | +| **Confidence** | High | +| **Practical Impact** | Low (single server) / Low–Medium (multi-server) | + +`TryDeserializeInstructions` allocates a `JsonDocument` — which rents a buffer from `ArrayPool` — and returns it via an `out` parameter. The caller uses the document's `RootElement` once, then allows the variable to go out of scope without calling `Dispose()`: + +```csharp +// Line 287 — JsonDocument created inside TryDeserializeInstructions +if (TryDeserializeInstructions(instruction, out JsonDocument? jsonInstructions) is false + && jsonInstructions is null) +{ + lastId = instruction.Id; + continue; +} + +// Line 293 — last use; jsonInstructions goes out of scope without Dispose() +List instructionBatch = GetAllInstructions(jsonInstructions?.RootElement); +``` + +`JsonDocument` has no finaliser. When the GC collects an un-disposed instance, the rented `ArrayPool` buffer is collected as ordinary heap memory rather than being returned to the pool. This reduces pool hit rates and increases allocation pressure. + +This codepath runs inside the multi-server cache instruction sync loop. On a **single-server** deployment the loop processes only local (skipped) instructions and almost never reaches `TryDeserializeInstructions`. On a **multi-server load-balanced** deployment with active content publishing, this can fire many times per minute. + +**Real-world impact over several days**: Negligible on single-server. On a busy multi-server site, slightly elevated Gen 0 GC frequency from reduced `ArrayPool` reuse. Memory does not grow unboundedly. + +**Recommended fix**: Wrap the `JsonDocument` in a `using` declaration at the call site: +```csharp +using JsonDocument? jsonInstructions = TryDeserializeInstructions(instruction); +if (jsonInstructions is null) { lastId = instruction.Id; continue; } +``` + +--- + +### Finding 3 — `JsonDocument` Cached Without Disposal on Eviction + +| | | +|---|---| +| **File** | `src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/JsonValueConverter.cs` | +| **Lines** | 52–68 | +| **Confidence** | Medium | +| **Practical Impact** | Low | + +`ConvertSourceToIntermediate` returns a `JsonDocument` that the published content cache stores at `PropertyCacheLevel.Element` (cached per content element, per variant): + +```csharp +public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Element; + +public override object? ConvertSourceToIntermediate(...) +{ + // ... + return JsonDocument.Parse(sourceString); // rented ArrayPool buffer not returned on eviction +} +``` + +The cache holds values as `object?` and evicts them by releasing references. Because there is no eviction callback that calls `Dispose()`, the rented buffer for each `JsonDocument` is abandoned rather than returned to the pool. + +This affects every content node with a JSON property type (block lists, media pickers, nested content, etc.). On a site with mostly-static content the cached `JsonDocument` population is bounded and stable. On a site with frequent content changes causing cache churn, pool hit rates are lower and allocation pressure is higher. + +**Real-world impact over several days**: Low. Memory does not grow unboundedly — the GC collects evicted documents. The observable effect, if any, would be marginally higher Gen 0 collection frequency on high-churn sites. This is unlikely to be measurable on a typical site. + +**Recommended fix**: This requires a non-trivial design change — either wrapping returned values in a disposable owner type with cache eviction callbacks, or switching the internal representation away from the pooled `JsonDocument` type. + +--- + +### Finding 4 — `CryptoStream` and `ICryptoTransform` Not Disposed + +| | | +|---|---| +| **File** | `src/Umbraco.Infrastructure/Security/MemberPasswordHasher.cs` | +| **Lines** | 161–171 | +| **Confidence** | Medium | +| **Practical Impact** | Negligible | + +In a legacy password decryption helper, `MemoryStream` is correctly wrapped in `using`, but `CryptoStream` and `ICryptoTransform` are not: + +```csharp +private static string DecryptLegacyPassword(string encryptedPassword, SymmetricAlgorithm algorithm) +{ + using var memoryStream = new MemoryStream(); + ICryptoTransform cryptoTransform = algorithm.CreateDecryptor(); // not disposed + var cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Write); // not disposed + var buf = Convert.FromBase64String(encryptedPassword); + cryptoStream.Write(buf, 0, 32); + cryptoStream.FlushFinalBlock(); + return Encoding.Unicode.GetString(memoryStream.ToArray()); +} +``` + +Both types implement `IDisposable` and hold internal transform state buffers. However, this method is only invoked for accounts with Umbraco ≤ 8 encrypted password hashes — a codepath that is exercised only during migrations from legacy installations and is effectively never called on a v17 site. + +**Real-world impact over several days**: None observable. The objects are small and collected promptly by the GC. + +**Recommended fix**: Add `using` declarations for both `cryptoTransform` and `cryptoStream` for correctness. + +--- + +### Finding 5 — Static Event Subscription Without Unsubscription (Development Mode Only) + +| | | +|---|---| +| **File** | `src/Umbraco.Cms.DevelopmentMode.Backoffice/InMemoryAuto/InMemoryAssemblyLoadContextManager.cs` | +| **Lines** | 10–11 | +| **Confidence** | High (pattern) | +| **Practical Impact** | None in production | + +The class subscribes to a static event in its constructor but implements no `IDisposable` to unsubscribe: + +```csharp +public InMemoryAssemblyLoadContextManager() => + AssemblyLoadContext.Default.Resolving += OnResolvingDefaultAssemblyLoadContext; +// No corresponding -= and no IDisposable +``` + +The class is registered as a singleton (`AddSingleton()`), so its lifetime matches the process and the omission is benign in normal operation. The static event would prevent GC if the DI container released its reference (e.g. during repeated host rebuilding in integration tests). This component is only active when `ModelsMode` is `InMemoryAuto` and `RuntimeMode` is `BackofficeDevelopment` — it is never loaded in production. + +**Real-world impact over several days**: None in production. Negligible in development. + +**Recommended fix**: Implement `IDisposable` and unsubscribe in `Dispose()` for correctness and test isolation. + +--- + +### Finding 6 — Static `HttpClient` Bypasses `IHttpClientFactory` + +| | | +|---|---| +| **File** | `src/Umbraco.Core/Media/EmbedProviders/OEmbedProviderBase.cs` | +| **Lines** | 13, 88–92 | +| **Confidence** | Low (not a true memory leak) | +| **Practical Impact** | Negligible (memory); Low (DNS staleness) | + +A static `HttpClient?` field is lazily initialised without using `IHttpClientFactory`: + +```csharp +private static HttpClient? _httpClient; + +if (_httpClient == null) +{ + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(...); +} +``` + +`HttpClient` is designed to be long-lived and reused, so the static pattern does not cause a memory leak. The practical concern is that DNS changes are not respected (no `PooledConnectionLifetime` on the underlying handler), which could cause stale connections on sites where OEmbed providers change their infrastructure. This is not a memory concern. + +**Real-world impact over several days**: No memory impact. Potential for stale DNS on OEmbed requests after several days if a provider changes their IP. + +**Recommended fix**: Inject `IHttpClientFactory` and use a named or typed client. + +--- + +### Finding 7 — Unbounded Static Regex Cache + +| | | +|---|---| +| **File** | `src/Umbraco.Core/Services/OEmbedService.cs` | +| **Lines** | 15, 68–69 | +| **Confidence** | Low | +| **Practical Impact** | Negligible | + +Compiled `Regex` objects are cached in a static `ConcurrentDictionary` with no eviction: + +```csharp +private static readonly ConcurrentDictionary RegexCache = new(); + +private static Regex GetOrCreateRegex(string pattern) + => RegexCache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.IgnoreCase | RegexOptions.Compiled)); +``` + +The dictionary is bounded by the number of unique URL scheme patterns across registered OEmbed providers, which is typically around 15–20 entries. Compiled `Regex` objects are intentionally long-lived. This is not a memory leak under normal usage; it would only become one if patterns were generated dynamically from user input at runtime (which they are not). + +**Real-world impact over several days**: None observable. + +**Recommended fix**: No action needed under current usage patterns. Add a size cap if the pattern set ever becomes dynamic. + +--- + +## Items Investigated and Cleared + +The following patterns were examined and found to be correctly implemented: + +| Class / Area | Pattern Checked | Result | +|---|---|---| +| `DatabaseServerMessenger._syncIdle` | `ManualResetEvent` disposal | ✓ Disposed at line 345 | +| `RecurringHostedServiceBase._timer` | `System.Threading.Timer` disposal | ✓ Disposed via `_timer?.Dispose()` | +| `DistributedBackgroundJobHostedService` | `PeriodicTimer` disposal | ✓ Wrapped in `using` | +| `RetryDbConnection` | `StateChange` event handler | ✓ Unsubscribed in `Dispose(bool)` | +| `UmbracoIdentityUser` | `ObservableCollection.CollectionChanged` | ✓ Cleaned up in property setters | +| `Content` / `ContentBase` / `ContentTypeBase` | `CollectionChanged` handlers | ✓ Use `ClearCollectionChangedEvents()` before reassignment | +| `FileRepository` / `PartialViewRepository` | `MemoryStream` returned from `GetContentStream` | ✓ All call sites wrap result in `using` | +| `JsonConfigManipulator` | `FileStream` disposal | ✓ Wrapped in `await using` | +| `QueuedHostedService` | `ExecutionContext.SuppressFlow()` | ✓ Wrapped in `using` | +| Background job DI registrations | Captive dependency (scoped-in-singleton) | ✓ No violations found | + +--- + +## Priority and Effort Summary + +| Priority | Finding | Fix Effort | +|---|---|---| +| **Fix** | Finding 1: `CancellationTokenSource` not disposed | 1 line | +| **Fix** | Finding 2: `JsonDocument` not disposed in sync loop | ~3 lines | +| **Fix** | Finding 4: `CryptoStream` not disposed | 2 lines | +| **Fix** | Finding 5: Static event leak (dev-only) | `IDisposable` implementation | +| **Consider** | Finding 3: `JsonDocument` cached without disposal | Design change required | +| **Consider** | Finding 6: Static `HttpClient` | Inject `IHttpClientFactory` | +| **Monitor** | Finding 7: Static `Regex` cache | No action unless patterns become dynamic | + +Findings 1, 2, and 4 are low-effort correctness fixes that follow established .NET resource management idioms. Finding 3 is a legitimate design smell that warrants a separate investigation into how the published content cache handles disposable cached values. diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 0dbd07c0e0ac..8feece4621e9 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -106,6 +106,10 @@ public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); // FIXME: remove this when Delivery API V1 is removed builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs b/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs index 3d4d4dacc3d1..61d9ef0a0c54 100644 --- a/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs +++ b/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Delivery.Handlers; @@ -13,7 +14,11 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler : INotificationAsyncHandler, INotificationAsyncHandler, INotificationAsyncHandler, - INotificationAsyncHandler + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler { private readonly IMemberService _memberService; private readonly IOpenIddictTokenManager _tokenManager; @@ -80,6 +85,38 @@ private async Task RevokeTokensAsync(IMember member) } } + public async Task HandleAsync(ExternalMemberSavedNotification notification, CancellationToken cancellationToken) + { + if (_enabled is false) + { + return; + } + + foreach (ExternalMemberIdentity member in notification.SavedEntities.Where(m => m.IsLockedOut || m.IsApproved is false)) + { + await RevokeTokensByKeyAsync(member.Key); + } + } + + public async Task HandleAsync(ExternalMemberDeletedNotification notification, CancellationToken cancellationToken) + { + if (_enabled is false) + { + return; + } + + foreach (ExternalMemberIdentity member in notification.DeletedEntities) + { + await RevokeTokensByKeyAsync(member.Key); + } + } + + public async Task HandleAsync(AssignedExternalMemberRolesNotification notification, CancellationToken cancellationToken) + => await ExternalMemberRolesChangedAsync(notification); + + public async Task HandleAsync(RemovedExternalMemberRolesNotification notification, CancellationToken cancellationToken) + => await ExternalMemberRolesChangedAsync(notification); + private async Task MemberRolesChangedAsync(MemberRolesNotification notification) { if (_enabled is false) @@ -99,4 +136,32 @@ private async Task MemberRolesChangedAsync(MemberRolesNotification notification) await RevokeTokensAsync(member); } } + + private async Task ExternalMemberRolesChangedAsync(ExternalMemberRolesNotification notification) + { + if (_enabled is false) + { + return; + } + + foreach (Guid memberKey in notification.MemberKeys) + { + await RevokeTokensByKeyAsync(memberKey); + } + } + + private async Task RevokeTokensByKeyAsync(Guid memberKey) + { + var tokens = await _tokenManager.FindBySubjectAsync(memberKey.ToString()).ToArrayAsync(); + if (tokens.Any() is false) + { + return; + } + + _logger.LogInformation("Revoking {count} active tokens for external member with key {key}", tokens.Length, memberKey); + foreach (var token in tokens) + { + await _tokenManager.DeleteAsync(token); + } + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/ByKeyMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/ByKeyMemberController.cs index ddb9d591e3d2..bb980cd3533c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/ByKeyMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/ByKeyMemberController.cs @@ -1,9 +1,11 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Api.Management.ViewModels.Member; -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -16,24 +18,43 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member; [ApiVersion("1.0")] public class ByKeyMemberController : MemberControllerBase { - private readonly IMemberEditingService _memberEditingService; - private readonly IMemberPresentationFactory _memberPresentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IMemberPresentationService _memberPresentationService; + + // TODO (V19): Remove the unnecessary parameters provided to the constructor. /// - /// Initializes a new instance of the class, which handles member management operations by member key. + /// Initializes a new instance of the class. /// /// Service used to perform editing operations on members. /// Factory for creating member presentation models. /// Accessor for back office security context. + /// Service for resolving members across both content and external stores. + [ActivatorUtilitiesConstructor] public ByKeyMemberController( IMemberEditingService memberEditingService, IMemberPresentationFactory memberPresentationFactory, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IMemberPresentationService memberPresentationService) { - _memberEditingService = memberEditingService; - _memberPresentationFactory = memberPresentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _memberPresentationService = memberPresentationService; + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public ByKeyMemberController( + IMemberEditingService memberEditingService, + IMemberPresentationFactory memberPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + memberEditingService, + memberPresentationFactory, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService()) + { } /// @@ -52,13 +73,7 @@ public ByKeyMemberController( [EndpointDescription("Gets a member identified by the provided Id.")] public async Task ByKey(CancellationToken cancellationToken, Guid id) { - IMember? member = await _memberEditingService.GetAsync(id); - if (member == null) - { - return MemberNotFound(); - } - - MemberResponseModel model = await _memberPresentationFactory.CreateResponseModelAsync(member, CurrentUser(_backOfficeSecurityAccessor)); - return Ok(model); + MemberResponseModel? model = await _memberPresentationService.CreateResponseModelByKeyAsync(id, CurrentUser(_backOfficeSecurityAccessor)); + return model is not null ? Ok(model) : MemberNotFound(); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/DeleteMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/DeleteMemberController.cs index 2663055b28dd..d0c4abd480f7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/DeleteMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/DeleteMemberController.cs @@ -19,11 +19,13 @@ public class DeleteMemberController : MemberControllerBase private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; /// - /// Initializes a new instance of the class, which handles member deletion operations. + /// Initializes a new instance of the class. /// /// Service used to perform member editing and deletion operations. /// Accessor for back office security context and authorization. - public DeleteMemberController(IMemberEditingService memberEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + public DeleteMemberController( + IMemberEditingService memberEditingService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { _memberEditingService = memberEditingService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs index be53a08f4372..a5db0dc8caa0 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/Filter/FilterMemberFilterController.cs @@ -1,10 +1,15 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Member; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; @@ -19,40 +24,48 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Filter; [ApiVersion("1.0")] public class FilterMemberFilterController : MemberFilterControllerBase { - private readonly IMemberService _memberService; + private readonly IMemberFilterService _memberFilterService; private readonly IMemberPresentationFactory _memberPresentationFactory; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; /// /// Initializes a new instance of the class. /// - /// Service used for member management operations. + /// Service used for member management operations (unused, retained for DI compatibility). /// Factory responsible for creating member presentation models. - /// Accessor for back office security context and authentication. + /// Accessor for back office security context (unused, retained for DI compatibility). + /// Service for combined member filtering across content and external stores. + // TODO (V19): Remove unused parameters which are only here to avoid ambiguous constructor errors. + [ActivatorUtilitiesConstructor] public FilterMemberFilterController( IMemberService memberService, IMemberPresentationFactory memberPresentationFactory, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IMemberFilterService memberFilterService) { - _memberService = memberService; + _memberFilterService = memberFilterService; _memberPresentationFactory = memberPresentationFactory; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public FilterMemberFilterController( + IMemberService memberService, + IMemberPresentationFactory memberPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : this( + memberService, + memberPresentationFactory, + backOfficeSecurityAccessor, + StaticServiceProvider.Instance.GetRequiredService()) + { } /// /// Retrieves a paged, filtered collection of members based on the specified criteria. + /// Returns both content-based and external-only members in a unified, correctly paginated result. /// - /// A token to monitor for cancellation requests. - /// An optional member type identifier to filter the results. - /// An optional member group name to filter the results. - /// An optional value to filter by member approval status. - /// An optional value to filter by member lockout status. - /// The field by which to order the results. The default is "username". - /// The direction in which to order the results. The default is . - /// An optional filter string to search for members. - /// The number of items to skip for pagination. The default is 0. - /// The number of items to return for pagination. The default is 100. - /// A task representing the asynchronous operation. The task result contains an with a representing the filtered members. [HttpGet] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] @@ -71,7 +84,7 @@ public async Task Filter( int skip = 0, int take = 100) { - var memberFilter = new MemberFilter() + var memberFilter = new MemberFilter { MemberTypeId = memberTypeId, MemberGroupName = memberGroupName, @@ -80,14 +93,14 @@ public async Task Filter( Filter = filter, }; - PagedModel members = await _memberService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take); + PagedModel result = await _memberFilterService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take); - var pageViewModel = new PagedViewModel - { - Items = await _memberPresentationFactory.CreateMultipleAsync(members.Items, CurrentUser(_backOfficeSecurityAccessor)), - Total = members.Total, - }; + var responseModels = result.Items.Select(_memberPresentationFactory.CreateFilterItemResponseModel).ToList(); - return Ok(pageViewModel); + return Ok(new PagedViewModel + { + Items = responseModels, + Total = result.Total, + }); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/Item/ItemMemberItemController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/Item/ItemMemberItemController.cs index 8da4949c5cd1..59ee7563e54e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/Item/ItemMemberItemController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/Item/ItemMemberItemController.cs @@ -1,11 +1,11 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Api.Management.ViewModels.Member.Item; -using Umbraco.Cms.Core.Mapping; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Management.Controllers.Member.Item; @@ -17,18 +17,37 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Item; [ApiVersion("1.0")] public class ItemMemberItemController : MemberItemControllerBase { - private readonly IEntityService _entityService; - private readonly IMemberPresentationFactory _memberPresentationFactory; + private readonly IMemberPresentationService _memberPresentationService; + + // TODO (V19): Remove the unnecessary parameters provided to the constructor. /// - /// Initializes a new instance of the class, which manages member item operations in the API. + /// Initializes a new instance of the class. /// /// Service used for entity operations and retrieval. /// Factory responsible for creating member presentation models. - public ItemMemberItemController(IEntityService entityService, IMemberPresentationFactory memberPresentationFactory) + /// Service for resolving members across both content and external stores. + [ActivatorUtilitiesConstructor] + public ItemMemberItemController( + IEntityService entityService, + IMemberPresentationFactory memberPresentationFactory, + IMemberPresentationService memberPresentationService) + { + _memberPresentationService = memberPresentationService; + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public ItemMemberItemController( + IEntityService entityService, + IMemberPresentationFactory memberPresentationFactory) + : this( + entityService, + memberPresentationFactory, + StaticServiceProvider.Instance.GetRequiredService()) { - _entityService = entityService; - _memberPresentationFactory = memberPresentationFactory; } [HttpGet] @@ -36,20 +55,16 @@ public ItemMemberItemController(IEntityService entityService, IMemberPresentatio [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [EndpointSummary("Gets a collection of member items.")] [EndpointDescription("Gets a collection of member items identified by the provided Ids.")] - public Task Item( + public async Task Item( CancellationToken cancellationToken, [FromQuery(Name = "id")] HashSet ids) { if (ids.Count is 0) { - return Task.FromResult(Ok(Enumerable.Empty())); + return Ok(Enumerable.Empty()); } - IEnumerable members = _entityService - .GetAll(UmbracoObjectTypes.Member, ids.ToArray()) - .OfType(); - - IEnumerable responseModels = members.Select(_memberPresentationFactory.CreateItemResponseModel); - return Task.FromResult(Ok(responseModels)); + IEnumerable responseModels = await _memberPresentationService.CreateItemResponseModelsAsync(ids); + return Ok(responseModels); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs index f8c09f54378a..a66f0efcb94f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/MemberControllerBase.cs @@ -96,6 +96,15 @@ protected IActionResult MemberEditingOperationStatusResult( where TContentModelBase : ContentModelBase => ContentEditingOperationStatusResult(status, requestModel, validationResult); + /// + /// Returns a 400 Bad Request indicating that external-only members cannot be modified through the Management API. + /// + protected IActionResult ExternalMemberCannotBeModified() + => BadRequest(new ProblemDetailsBuilder() + .WithTitle("External member cannot be modified") + .WithDetail("This member is managed by an external provider. Content operations such as create, update, and property editing are not available for external-only members.") + .Build()); + private IActionResult MemberNotFound(ProblemDetailsBuilder problemDetailsBuilder) => NotFound(problemDetailsBuilder .WithTitle("The requested member could not be found") .Build()); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs index d250c81fe087..4cc17f4d51cd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs @@ -1,10 +1,13 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -17,20 +20,39 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.References; [ApiVersion("1.0")] public class ReferencedByMemberController : MemberControllerBase { - private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; + private readonly IMemberReferenceService _memberReferenceService; + + // TODO (V19): Remove the unnecessary parameters provided to the constructor. /// /// Initializes a new instance of the class. /// /// An implementation of used to manage tracked references. /// An implementation of used to create relation type presentations. + /// Service for retrieving paged references to a member. + [ActivatorUtilitiesConstructor] public ReferencedByMemberController( ITrackedReferencesService trackedReferencesService, - IRelationTypePresentationFactory relationTypePresentationFactory) + IRelationTypePresentationFactory relationTypePresentationFactory, + IMemberReferenceService memberReferenceService) { - _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; + _memberReferenceService = memberReferenceService; + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public ReferencedByMemberController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) + : this( + trackedReferencesService, + relationTypePresentationFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { } /// @@ -52,12 +74,12 @@ public async Task>> Referen int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> result = await _memberReferenceService.GetPagedReferencesAsync(id, skip, take); var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + Total = result.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(result.Result.Items), }; return pagedViewModel; @@ -87,17 +109,17 @@ public async Task ReferencedBy2( int skip = 0, int take = 20) { - Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true); + Attempt, GetReferencesOperationStatus> result = await _memberReferenceService.GetPagedReferencesAsync(id, skip, take); - if (relationItemsAttempt.Success is false) + if (result.Success is false) { - return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + return GetReferencesOperationStatusResult(result.Status); } var pagedViewModel = new PagedViewModel { - Total = relationItemsAttempt.Result.Total, - Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), + Total = result.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(result.Result.Items), }; return Ok(pagedViewModel); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/UpdateMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/UpdateMemberController.cs index c0416a8f2af3..b2b84c1249a3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/UpdateMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/UpdateMemberController.cs @@ -22,7 +22,7 @@ public class UpdateMemberController : MemberControllerBase private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; /// - /// Initializes a new instance of the class, responsible for handling member update operations in the management API. + /// Initializes a new instance of the class. /// /// Service used to perform member editing operations. /// Factory for creating presentation models related to member editing. @@ -49,6 +49,13 @@ public async Task Update( Guid id, UpdateMemberRequestModel updateRequestModel) { + // External-only members cannot be updated through this endpoint. + // Their identity data is managed by the external provider. + if (await _memberEditingService.IsExternalMemberAsync(id)) + { + return ExternalMemberCannotBeModified(); + } + MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(updateRequestModel); Attempt result = await _memberEditingService.UpdateAsync(id, model, CurrentUser(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/ValidateUpdateMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/ValidateUpdateMemberController.cs index e0efd3eb1ced..56a82414a497 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/ValidateUpdateMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/ValidateUpdateMemberController.cs @@ -20,7 +20,7 @@ public class ValidateUpdateMemberController : MemberControllerBase private readonly IMemberEditingPresentationFactory _memberEditingPresentationFactory; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The used for member editing operations. /// The used to create member editing presentations. @@ -44,6 +44,12 @@ public async Task Validate( Guid id, UpdateMemberRequestModel requestModel) { + // External-only members cannot be updated through this endpoint. + if (await _memberEditingService.IsExternalMemberAsync(id)) + { + return ExternalMemberCannotBeModified(); + } + MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(requestModel); Attempt result = await _memberEditingService.ValidateUpdateAsync(id, model); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs index b5a5bb842cb6..7df94a684fbe 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Member; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; @@ -12,6 +13,8 @@ internal static IUmbracoBuilder AddMember(this IUmbracoBuilder builder) { builder.Services.AddSingleton(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder().Add(); diff --git a/src/Umbraco.Cms.Api.Management/Factories/IMemberPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IMemberPresentationFactory.cs index 8ab1ead58bc3..e9151780d926 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IMemberPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IMemberPresentationFactory.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Api.Management.Factories; @@ -40,4 +41,31 @@ public interface IMemberPresentationFactory /// The member entity to create the response model from. /// A MemberItemResponseModel representing the member entity. MemberItemResponseModel CreateItemResponseModel(IMember entity); + + /// + /// Creates a response model for an external-only member. + /// + /// The external member identity to create the response model from. + /// A task that represents the asynchronous operation. The task result contains the . + // TODO (V19): Remove the default implementation. + Task CreateExternalMemberResponseModelAsync(ExternalMemberIdentity member) + => Task.FromResult(new MemberResponseModel { Id = member.Key, Kind = MemberKind.ExternalOnly }); + + /// + /// Creates an item response model for an external-only member. + /// + /// The external member identity to create the item response model from. + /// A representing the external member. + // TODO (V19): Remove the default implementation. + MemberItemResponseModel CreateExternalMemberItemResponseModel(ExternalMemberIdentity member) + => new() { Id = member.Key, Kind = MemberKind.ExternalOnly }; + + /// + /// Creates a response model from a returned by the combined filter query. + /// + /// The filter item to create the response model from. + /// A representing the filter item. + // TODO (V19): Remove the default implementation. + MemberResponseModel CreateFilterItemResponseModel(MemberFilterItem item) + => new() { Id = item.Key, Kind = item.Kind }; } diff --git a/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs index b242c0066a45..419ffc605e88 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs @@ -9,11 +9,13 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Factories; +/// internal sealed class MemberPresentationFactory : IMemberPresentationFactory { private readonly IUmbracoMapper _umbracoMapper; @@ -22,6 +24,7 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory private readonly ITwoFactorLoginService _twoFactorLoginService; private readonly IMemberGroupService _memberGroupService; private readonly DeliveryApiSettings _deliveryApiSettings; + private readonly IExternalMemberService _externalMemberService; private IEnumerable? _clientCredentialsMemberKeys; /// @@ -33,13 +36,15 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory /// Service for handling two-factor authentication for members. /// Service for managing member groups. /// The configuration options for the Delivery API. + /// Service for managing external-only members. public MemberPresentationFactory( IUmbracoMapper umbracoMapper, IMemberService memberService, IMemberTypeService memberTypeService, ITwoFactorLoginService twoFactorLoginService, IMemberGroupService memberGroupService, - IOptions deliveryApiSettings) + IOptions deliveryApiSettings, + IExternalMemberService externalMemberService) { _umbracoMapper = umbracoMapper; _memberService = memberService; @@ -47,14 +52,10 @@ public MemberPresentationFactory( _twoFactorLoginService = twoFactorLoginService; _memberGroupService = memberGroupService; _deliveryApiSettings = deliveryApiSettings.Value; + _externalMemberService = externalMemberService; } - /// - /// Asynchronously creates a for the specified , including or excluding sensitive data based on the current user's permissions. - /// - /// The member entity to map to a response model. - /// The user requesting the data, used to determine access to sensitive information. - /// A task representing the asynchronous operation, with a as the result. + /// public async Task CreateResponseModelAsync(IMember member, IUser currentUser) { MemberResponseModel responseModel = _umbracoMapper.Map(member)!; @@ -70,6 +71,7 @@ public async Task CreateResponseModelAsync(IMember member, : await RemoveSensitiveDataAsync(member, responseModel); } + /// public async Task> CreateMultipleAsync(IEnumerable members, IUser currentUser) { var memberResponseModels = new List(); @@ -81,41 +83,101 @@ public async Task> CreateMultipleAsync(IEnumera return memberResponseModels; } - /// - /// Creates a response model for a member item from the given entity. - /// - /// The member entity to create the response model from. - /// A representing the member. + /// public MemberItemResponseModel CreateItemResponseModel(IMemberEntitySlim entity) => CreateItemResponseModel(entity); - /// - /// Creates a response model for a member item based on the given member entity. - /// - /// The member entity to create the response model from. - /// A representing the member. + /// public MemberItemResponseModel CreateItemResponseModel(IMember entity) => CreateItemResponseModel(entity); + /// + public async Task CreateExternalMemberResponseModelAsync(ExternalMemberIdentity member) + { + IEnumerable roles = await _externalMemberService.GetRolesAsync(member.Key); + IEnumerable groupKeys = roles + .Select(x => _memberGroupService.GetByName(x)) + .WhereNotNull() + .Select(x => x.Key) + .ToArray(); + + return new MemberResponseModel + { + Id = member.Key, + Email = member.Email, + Username = member.UserName, + IsApproved = member.IsApproved, + IsLockedOut = member.IsLockedOut, + IsTwoFactorEnabled = false, + FailedPasswordAttempts = 0, + LastLoginDate = member.LastLoginDate.HasValue ? new DateTimeOffset(member.LastLoginDate.Value, TimeSpan.Zero) : null, + LastLockoutDate = member.LastLockoutDate.HasValue ? new DateTimeOffset(member.LastLockoutDate.Value, TimeSpan.Zero) : null, + LastPasswordChangeDate = null, + Kind = MemberKind.ExternalOnly, + Variants = [new MemberVariantResponseModel + { + Name = member.Name ?? string.Empty, + CreateDate = new DateTimeOffset(member.CreateDate, TimeSpan.Zero), + UpdateDate = new DateTimeOffset(member.UpdateDate, TimeSpan.Zero), + }], + Values = Enumerable.Empty(), + MemberType = new MemberTypeReferenceResponseModel(), + Groups = groupKeys, + ProfileData = member.ProfileData, + }; + } + + /// + public MemberItemResponseModel CreateExternalMemberItemResponseModel(ExternalMemberIdentity member) => + new() + { + Id = member.Key, + MemberType = new MemberTypeReferenceResponseModel(), + Variants = [new VariantItemResponseModel { Name = member.Name ?? string.Empty, Culture = null }], + Kind = MemberKind.ExternalOnly, + }; + + /// + public MemberResponseModel CreateFilterItemResponseModel(MemberFilterItem item) => + new() + { + Id = item.Key, + Email = item.Email, + Username = item.UserName, + IsApproved = item.IsApproved, + IsLockedOut = item.IsLockedOut, + LastLoginDate = item.LastLoginDate.HasValue ? new DateTimeOffset(item.LastLoginDate.Value, TimeSpan.Zero) : null, + LastLockoutDate = item.LastLockoutDate.HasValue ? new DateTimeOffset(item.LastLockoutDate.Value, TimeSpan.Zero) : null, + LastPasswordChangeDate = item.LastPasswordChangeDate.HasValue ? new DateTimeOffset(item.LastPasswordChangeDate.Value, TimeSpan.Zero) : null, + Kind = item.Kind, + Variants = [new MemberVariantResponseModel { Name = item.Name ?? string.Empty }], + Values = [], + MemberType = new MemberTypeReferenceResponseModel + { + Id = item.MemberTypeKey ?? Guid.Empty, + Icon = item.MemberTypeIcon ?? string.Empty, + }, + }; + private MemberItemResponseModel CreateItemResponseModel(T entity) where T : ITreeEntity - => new MemberItemResponseModel + => new() { Id = entity.Key, MemberType = _umbracoMapper.Map(entity)!, Variants = CreateVariantsItemResponseModels(entity), - Kind = GetMemberKind(entity.Key) + Kind = GetMemberKind(entity.Key), }; private static IEnumerable CreateVariantsItemResponseModels(ITreeEntity entity) - => new[] - { + => + [ new VariantItemResponseModel { Name = entity.Name ?? string.Empty, - Culture = null + Culture = null, } - }; + ]; private async Task RemoveSensitiveDataAsync(IMember member, MemberResponseModel responseModel) { diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs index 029ca6dc0b77..fe2c623dfdc9 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs @@ -48,7 +48,7 @@ public MemberMapDefinition( public void DefineMaps(IUmbracoMapper mapper) => mapper.Define((_, _) => new MemberResponseModel(), Map); - // Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind -Flags + // Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind -Flags -ProfileData private void Map(IMember source, MemberResponseModel target, MapperContext context) { target.Id = source.Key; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 0885426e2c11..f7445ef5df78 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -9632,6 +9632,157 @@ ] } }, + "/umbraco/management/api/v1/document/{id}/patch": { + "patch": { + "tags": [ + "Document" + ], + "summary": "Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec", + "operationId": "PatchDocumentByIdPatch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PatchDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user does not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice-User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/document/{id}/preview-url": { "get": { "tags": [ @@ -45917,7 +46068,8 @@ "MemberKindModel": { "enum": [ "Default", - "Api" + "Api", + "ExternalOnly" ], "type": "string" }, @@ -46058,6 +46210,10 @@ }, "kind": { "$ref": "#/components/schemas/MemberKindModel" + }, + "profileData": { + "type": "string", + "nullable": true } }, "additionalProperties": false @@ -48761,6 +48917,47 @@ }, "additionalProperties": false }, + "PatchDocumentRequestModel": { + "required": [ + "operations" + ], + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/PatchOperationRequestModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "PatchOperationRequestModel": { + "required": [ + "op", + "path" + ], + "type": "object", + "properties": { + "op": { + "minLength": 1, + "type": "string" + }, + "path": { + "minLength": 1, + "type": "string" + }, + "value": { + "nullable": true + } + }, + "additionalProperties": false + }, "ProblemDetails": { "type": "object", "properties": { @@ -53245,4 +53442,4 @@ "name": "Webhook" } ] -} +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/Services/IMemberPresentationService.cs b/src/Umbraco.Cms.Api.Management/Services/IMemberPresentationService.cs new file mode 100644 index 000000000000..1ed1ea6f3a37 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/IMemberPresentationService.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Api.Management.ViewModels.Member; +using Umbraco.Cms.Api.Management.ViewModels.Member.Item; +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Service for resolving members across both content and external stores and creating presentation models. +/// +public interface IMemberPresentationService +{ + /// + /// Resolves a member by key from either the content or external store and creates a response model. + /// + /// The unique identifier of the member. + /// The current backoffice user performing the operation. + /// A if found; otherwise null. + Task CreateResponseModelByKeyAsync(Guid id, IUser currentUser); + + /// + /// Resolves members by keys from both the content and external stores and creates item response models. + /// + /// The unique identifiers of the members to resolve. + /// A collection of for all resolved members. + Task> CreateItemResponseModelsAsync(HashSet ids); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/IMemberReferenceService.cs b/src/Umbraco.Cms.Api.Management/Services/IMemberReferenceService.cs new file mode 100644 index 000000000000..d6f865140a68 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/IMemberReferenceService.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Service for retrieving paged references to a member, handling the external member fallback +/// when the entity-based lookup fails (external members have no umbracoNode entry). +/// +public interface IMemberReferenceService +{ + /// + /// Gets a paged list of items that reference the specified member. + /// + /// The unique identifier of the member. + /// The number of items to skip. + /// The maximum number of items to return. + /// An containing the paged relation items or an operation status on failure. + Task, GetReferencesOperationStatus>> GetPagedReferencesAsync(Guid id, int skip, int take); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/MemberPresentationService.cs b/src/Umbraco.Cms.Api.Management/Services/MemberPresentationService.cs new file mode 100644 index 000000000000..07f6061dd26a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/MemberPresentationService.cs @@ -0,0 +1,79 @@ +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Member; +using Umbraco.Cms.Api.Management.ViewModels.Member.Item; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Resolves members across both the content and external member stores and creates presentation models. +/// +internal sealed class MemberPresentationService : IMemberPresentationService +{ + private readonly IEntityService _entityService; + private readonly IMemberEditingService _memberEditingService; + private readonly IMemberPresentationFactory _memberPresentationFactory; + + /// + /// Initializes a new instance of the class. + /// + /// Service used for entity operations and retrieval. + /// Service used for member editing operations. + /// Factory responsible for creating member presentation models. + public MemberPresentationService( + IEntityService entityService, + IMemberEditingService memberEditingService, + IMemberPresentationFactory memberPresentationFactory) + { + _entityService = entityService; + _memberEditingService = memberEditingService; + _memberPresentationFactory = memberPresentationFactory; + } + + /// + public async Task CreateResponseModelByKeyAsync(Guid id, IUser currentUser) + { + IMember? member = await _memberEditingService.GetAsync(id); + if (member is not null) + { + return await _memberPresentationFactory.CreateResponseModelAsync(member, currentUser); + } + + ExternalMemberIdentity? externalMember = await _memberEditingService.GetExternalMemberAsync(id); + if (externalMember is not null) + { + return await _memberPresentationFactory.CreateExternalMemberResponseModelAsync(externalMember); + } + + return null; + } + + /// + public async Task> CreateItemResponseModelsAsync(HashSet ids) + { + IMemberEntitySlim[] contentMembers = _entityService + .GetAll(UmbracoObjectTypes.Member, ids.ToArray()) + .OfType() + .ToArray(); + + var responseModels = new List( + contentMembers.Select(_memberPresentationFactory.CreateItemResponseModel)); + + var resolvedIds = contentMembers.Select(m => m.Key).ToHashSet(); + + foreach (Guid unresolvedId in ids.Where(id => resolvedIds.Contains(id) is false)) + { + ExternalMemberIdentity? externalMember = await _memberEditingService.GetExternalMemberAsync(unresolvedId); + if (externalMember is not null) + { + responseModels.Add(_memberPresentationFactory.CreateExternalMemberItemResponseModel(externalMember)); + } + } + + return responseModels; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Services/MemberReferenceService.cs b/src/Umbraco.Cms.Api.Management/Services/MemberReferenceService.cs new file mode 100644 index 000000000000..fc79e279ac92 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/MemberReferenceService.cs @@ -0,0 +1,55 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Retrieves paged references to a member, handling the external member fallback +/// when the entity-based lookup fails (external members have no umbracoNode entry). +/// +internal sealed class MemberReferenceService : IMemberReferenceService +{ + private readonly ITrackedReferencesService _trackedReferencesService; + private readonly IMemberEditingService _memberEditingService; + + /// + /// Initializes a new instance of the class. + /// + /// Service used to manage tracked references. + /// Service used for member editing operations. + public MemberReferenceService( + ITrackedReferencesService trackedReferencesService, + IMemberEditingService memberEditingService) + { + _trackedReferencesService = trackedReferencesService; + _memberEditingService = memberEditingService; + } + + /// + public async Task, GetReferencesOperationStatus>> GetPagedReferencesAsync(Guid id, int skip, int take) + { + Attempt, GetReferencesOperationStatus> result = + await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true); + + if (result.Success) + { + return result; + } + + // The entity-based lookup fails for external-only members (no umbracoNode entry). + // Fall back to a key-based relation query if this is an external member. + if (result.Status == GetReferencesOperationStatus.ContentNotFound + && await _memberEditingService.IsExternalMemberAsync(id)) + { +#pragma warning disable CS0618 // Type or member is obsolete — using the key-based overload that doesn't require an entity. + PagedModel externalRelations = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); +#pragma warning restore CS0618 + + return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, externalRelations); + } + + return result; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs index 9076d605a46f..6bfee2af5f01 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs @@ -62,4 +62,15 @@ public class MemberResponseModel : ContentResponseModelBase public MemberKind Kind { get; set; } + + /// + /// Gets or sets the raw JSON profile data for external-only members. + /// + /// + /// Populated only for members whose is , + /// from . The shape is + /// integrator-defined (typically claims serialised by an OnExternalLogin handler), so the + /// API returns the raw JSON string and leaves interpretation to the consumer. + /// + public string? ProfileData { get; set; } } diff --git a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs index d08cf004c685..616fd7402f53 100644 --- a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs +++ b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs @@ -1,10 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.Changes; namespace Umbraco.Extensions; @@ -308,12 +310,28 @@ public static void RemoveMemberCache(this DistributedCache dc, IEnumerableAn enumerable of JSON payloads for the member cache refresher. /// Internal for unit test. internal static IEnumerable GetPayloads(IEnumerable members, IDictionary state, bool removed) - => members + { + bool indexableFieldsChanged = GetMemberIndexableFieldsChanged(state); + return members .DistinctBy(x => (x.Id, x.Username)) - .Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed) + .Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed, indexableFieldsChanged) { PreviousUsername = GetPreviousUsername(x, state) }); + } + + private static bool GetMemberIndexableFieldsChanged(IDictionary state) + { + // Default to true for backward compatibility — any save that doesn't explicitly signal + // "nothing indexable changed" is treated as potentially indexable. + if (state.TryGetValue(Constants.Conventions.Member.IndexableFieldsChangedStateKey, out object? value) + && value is bool flag) + { + return flag; + } + + return true; + } private static string? GetPreviousUsername(IMember x, IDictionary state) { @@ -334,6 +352,59 @@ public static void RemoveMemberCache(this DistributedCache dc, IEnumerable + /// Refreshes the specified external members in the distributed cache. + /// + /// The distributed cache. + /// The external members to refresh in cache. + [Obsolete("Use the overload taking notification state instead. Scheduled for removal in Umbraco 19.")] + public static void RefreshExternalMemberCache(this DistributedCache dc, IEnumerable externalMembers) + => dc.RefreshExternalMemberCache(externalMembers, new Dictionary()); + + /// + /// Refreshes the specified external members in the distributed cache. + /// + /// The distributed cache. + /// The external members to refresh in cache. + /// The notification state dictionary. + public static void RefreshExternalMemberCache(this DistributedCache dc, IEnumerable externalMembers, IDictionary state) + => dc.RefreshByPayload( + ExternalMemberCacheRefresher.UniqueId, + GetPayloads(externalMembers, state, removed: false)); + + /// + /// Removes the specified external members from the distributed cache. + /// + /// The distributed cache. + /// The external members to remove from cache. + public static void RemoveExternalMemberCache(this DistributedCache dc, IEnumerable externalMembers) + => dc.RefreshByPayload( + ExternalMemberCacheRefresher.UniqueId, + GetPayloads(externalMembers, new Dictionary(), removed: true)); + + /// + /// Gets the JSON payloads for external member cache refresh operations. + /// + /// The external members to create payloads for. + /// The notification state dictionary. + /// Whether the external members were removed. + /// An enumerable of JSON payloads for the external member cache refresher. + /// Internal for unit test. + internal static IEnumerable GetPayloads( + IEnumerable externalMembers, + IDictionary state, + bool removed) + { + bool indexableFieldsChanged = GetMemberIndexableFieldsChanged(state); + return externalMembers + .DistinctBy(x => x.Key) + .Select(x => new ExternalMemberCacheRefresher.JsonPayload(x.Id, x.Key, removed, indexableFieldsChanged)); + } + + #endregion + #region MemberGroupCacheRefresher /// diff --git a/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ExternalMemberDeletedDistributedCacheNotificationHandler.cs b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ExternalMemberDeletedDistributedCacheNotificationHandler.cs new file mode 100644 index 000000000000..21476f62429e --- /dev/null +++ b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ExternalMemberDeletedDistributedCacheNotificationHandler.cs @@ -0,0 +1,30 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +/// +public sealed class ExternalMemberDeletedDistributedCacheNotificationHandler : DeletedDistributedCacheNotificationHandlerBase +{ + private readonly DistributedCache _distributedCache; + + /// + /// Initializes a new instance of the class. + /// + /// The distributed cache. + public ExternalMemberDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache) + => _distributedCache = distributedCache; + + /// + [Obsolete("Scheduled for removal in Umbraco 19.")] + protected override void Handle(IEnumerable entities) + => Handle(entities, new Dictionary()); + + /// + protected override void Handle(IEnumerable entities, IDictionary state) + => _distributedCache.RemoveExternalMemberCache(entities); +} diff --git a/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ExternalMemberSavedDistributedCacheNotificationHandler.cs b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ExternalMemberSavedDistributedCacheNotificationHandler.cs new file mode 100644 index 000000000000..680142074727 --- /dev/null +++ b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ExternalMemberSavedDistributedCacheNotificationHandler.cs @@ -0,0 +1,30 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +/// +public sealed class ExternalMemberSavedDistributedCacheNotificationHandler : SavedDistributedCacheNotificationHandlerBase +{ + private readonly DistributedCache _distributedCache; + + /// + /// Initializes a new instance of the class. + /// + /// The distributed cache. + public ExternalMemberSavedDistributedCacheNotificationHandler(DistributedCache distributedCache) + => _distributedCache = distributedCache; + + /// + [Obsolete("Scheduled for removal in Umbraco 19.")] + protected override void Handle(IEnumerable entities) + => Handle(entities, new Dictionary()); + + /// + protected override void Handle(IEnumerable entities, IDictionary state) + => _distributedCache.RefreshExternalMemberCache(entities, state); +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ExternalMemberCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ExternalMemberCacheRefresher.cs new file mode 100644 index 000000000000..44d06d8ad38b --- /dev/null +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ExternalMemberCacheRefresher.cs @@ -0,0 +1,96 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.Cache; + +/// +/// Cache refresher for external member caches. +/// +public sealed class ExternalMemberCacheRefresher : PayloadCacheRefresherBase +{ + /// + /// The unique identifier for this cache refresher. + /// + public static readonly Guid UniqueId = Guid.Parse("A1B2C3D4-5E6F-4A8B-9C0D-E1F2A3B4C5D6"); + + /// + /// Initializes a new instance of the class. + /// + /// The application caches. + /// The JSON serializer. + /// The event aggregator. + /// The cache refresher notification factory. + public ExternalMemberCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory) + : base(appCaches, serializer, eventAggregator, factory) + { + } + + /// + /// Represents the JSON payload for external member cache refresh operations. + /// + public class JsonPayload + { + /// + /// Initializes a new instance of the class. + /// + /// The integer identifier of the external member. + /// The unique key of the external member. + /// Whether the external member was removed. + /// + /// Whether any field that is part of the Examine value set has changed as part of this operation. + /// Defaults to true. When false, Examine indexing handlers skip the re-index for this payload. + /// + public JsonPayload(int id, Guid key, bool removed, bool indexableFieldsChanged = true) + { + Id = id; + Key = key; + Removed = removed; + IndexableFieldsChanged = indexableFieldsChanged; + } + + /// + /// Gets the integer identifier of the external member (used as the Examine document ID). + /// + public int Id { get; } + + /// + /// Gets the unique key of the external member. + /// + public Guid Key { get; } + + /// + /// Gets a value indicating whether the external member was removed. + /// + public bool Removed { get; } + + /// + /// Gets a value indicating whether any indexable field changed as part of the originating save. + /// + /// + /// Explicitly set to false on login-only updates (which do not bump + /// UpdateDate) so that the Examine indexing handlers skip re-indexing this payload. + /// + public bool IndexableFieldsChanged { get; } + } + + /// + public override Guid RefresherUniqueId => UniqueId; + + /// + public override string Name => "External Member Cache Refresher"; + + /// + public override void RefreshInternal(JsonPayload[] payloads) + { + // External members have no content cache to clear. + base.RefreshInternal(payloads); + } +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs index b4c72025f354..9164f648253b 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs @@ -87,10 +87,27 @@ public class JsonPayload /// The username of the member. /// Whether the member was removed. public JsonPayload(int id, string? username, bool removed) + : this(id, username, removed, indexableFieldsChanged: true) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the member. + /// The username of the member. + /// Whether the member was removed. + /// + /// Whether any field that is part of the Examine value set has changed as part of this operation. + /// When false, Examine indexing handlers will skip the re-index for this payload. + /// + [System.Text.Json.Serialization.JsonConstructor] + public JsonPayload(int id, string? username, bool removed, bool indexableFieldsChanged) { Id = id; Username = username; Removed = removed; + IndexableFieldsChanged = indexableFieldsChanged; } /// @@ -112,6 +129,16 @@ public JsonPayload(int id, string? username, bool removed) /// Gets a value indicating whether the member was removed. /// public bool Removed { get; } + + /// + /// Gets a value indicating whether any indexable field changed as part of the originating save. + /// + /// + /// Defaults to true for backward compatibility. Explicitly set to false + /// on login-only updates (which do not bump UpdateDate) so that the Examine + /// indexing handlers skip re-indexing this payload. + /// + public bool IndexableFieldsChanged { get; } = true; } /// diff --git a/src/Umbraco.Core/Constants-Conventions.cs b/src/Umbraco.Core/Constants-Conventions.cs index 865aaed3cdd4..a590904121f7 100644 --- a/src/Umbraco.Core/Constants-Conventions.cs +++ b/src/Umbraco.Core/Constants-Conventions.cs @@ -258,6 +258,18 @@ public static class Member /// if a role starts with __umbracoRole we won't show it as it's an internal role used for public access /// public static readonly string InternalRolePrefix = "__umbracoRole"; + + /// + /// Notification-state key that flags a save as touching only login-related properties + /// (e.g. LastLoginDate, SecurityStamp). + /// + public const string LoginPropertiesOnlyStateKey = "LoginPropertiesOnly"; + + /// + /// Notification-state key that indicates whether any indexable field changed as part of the save. + /// When explicitly set to false, Examine indexing for the affected member is skipped. + /// + public const string IndexableFieldsChangedStateKey = "IndexableFieldsChanged"; } /// diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 3aa603df424b..3347ec0936a2 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -320,6 +320,7 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs index b739ac0c217b..8519309247c9 100644 --- a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs +++ b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs @@ -47,7 +47,11 @@ public sealed class AuditNotificationsHandler : INotificationHandler, INotificationAsyncHandler, INotificationHandler, - INotificationAsyncHandler + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler { private readonly IAuditEntryService _auditEntryService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -303,6 +307,68 @@ await Audit( public void Handle(RemovedMemberRolesNotification notification) => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + /// + public async Task HandleAsync(ExternalMemberSavedNotification notification, CancellationToken cancellationToken) + { + IUser? performingUser = await GetCurrentPerformingUser(); + foreach (ExternalMemberIdentity member in notification.SavedEntities) + { + await Audit( + performingUser, + null, + affectedDetails: FormatExternalMemberDetails(member), + "umbraco/member/save", + "updating external member"); + } + } + + /// + public async Task HandleAsync(ExternalMemberDeletedNotification notification, CancellationToken cancellationToken) + { + IUser? performingUser = await GetCurrentPerformingUser(); + foreach (ExternalMemberIdentity member in notification.DeletedEntities) + { + await Audit( + performingUser, + null, + affectedDetails: FormatExternalMemberDetails(member), + "umbraco/member/delete", + $"delete external member \"{member.Name}\""); + } + } + + /// + public async Task HandleAsync(AssignedExternalMemberRolesNotification notification, CancellationToken cancellationToken) + { + IUser? performingUser = await GetCurrentPerformingUser(); + var roles = string.Join(", ", notification.Roles); + foreach (Guid memberKey in notification.MemberKeys) + { + await Audit( + performingUser, + null, + affectedDetails: $"External member {memberKey}", + "umbraco/member/roles/assigned", + $"roles modified, assigned {roles}"); + } + } + + /// + public async Task HandleAsync(RemovedExternalMemberRolesNotification notification, CancellationToken cancellationToken) + { + IUser? performingUser = await GetCurrentPerformingUser(); + var roles = string.Join(", ", notification.Roles); + foreach (Guid memberKey in notification.MemberKeys) + { + await Audit( + performingUser, + null, + affectedDetails: $"External member {memberKey}", + "umbraco/member/roles/removed", + $"roles modified, removed {roles}"); + } + } + /// public async Task HandleAsync(UserDeletedNotification notification, CancellationToken cancellationToken) { @@ -504,4 +570,17 @@ private static string FormatDetails(int id, IMember? member, bool appendType = f /// The email address to format. /// The email wrapped in angle brackets, or null if the email is empty. private static string? FormatEmail(string? email) => !email.IsNullOrWhiteSpace() ? $"<{email}>" : null; + + /// + /// Formats external member details for audit logging. + /// + /// The external member identity to format details for. + /// A formatted string containing external member details. + private static string FormatExternalMemberDetails(ExternalMemberIdentity member) + { + var name = member.Name ?? "(unknown)"; + var details = $"External member \"{name}\""; + var email = FormatEmail(member.Email); + return email is not null ? $"{details} {email}" : details; + } } diff --git a/src/Umbraco.Core/Models/Membership/MemberFilterItem.cs b/src/Umbraco.Core/Models/Membership/MemberFilterItem.cs new file mode 100644 index 000000000000..f92719089c4c --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/MemberFilterItem.cs @@ -0,0 +1,80 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Models.Membership; + +/// +/// Represents a member in a filtered listing, covering both content-based and external-only members. +/// +public class MemberFilterItem +{ + /// + /// Gets or sets the unique key of the member. + /// + public Guid Key { get; set; } + + /// + /// Gets or sets the email address. + /// + public string Email { get; set; } = string.Empty; + + /// + /// Gets or sets the username. + /// + public string UserName { get; set; } = string.Empty; + + /// + /// Gets or sets the display name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether the member is approved. + /// + public bool IsApproved { get; set; } + + /// + /// Gets or sets a value indicating whether the member is locked out. + /// + public bool IsLockedOut { get; set; } + + /// + /// Gets or sets the last login date. + /// + public DateTime? LastLoginDate { get; set; } + + /// + /// Gets or sets the last lockout date. + /// + public DateTime? LastLockoutDate { get; set; } + + /// + /// Gets or sets the last password change date. + /// + public DateTime? LastPasswordChangeDate { get; set; } + + /// + /// Gets or sets a value indicating whether this is an external-only member. + /// + public bool IsExternalOnly { get; set; } + + /// + /// Gets or sets the member type key. Null for external-only members. + /// + public Guid? MemberTypeKey { get; set; } + + /// + /// Gets or sets the member type name. Null for external-only members. + /// + public string? MemberTypeName { get; set; } + + /// + /// Gets or sets the member type icon. Null for external-only members. + /// + public string? MemberTypeIcon { get; set; } + + /// + /// Gets or sets the member kind. + /// + public MemberKind Kind { get; set; } +} diff --git a/src/Umbraco.Core/Models/Membership/MemberKind.cs b/src/Umbraco.Core/Models/Membership/MemberKind.cs index ab61b46e92cb..b2340315f08f 100644 --- a/src/Umbraco.Core/Models/Membership/MemberKind.cs +++ b/src/Umbraco.Core/Models/Membership/MemberKind.cs @@ -13,5 +13,11 @@ public enum MemberKind /// /// A member created through the API. /// - Api + Api, + + /// + /// An external-only member backed by the lightweight umbracoExternalMember table, + /// not the content system. Authenticated via an external provider. + /// + ExternalOnly } diff --git a/src/Umbraco.Core/Notifications/AssignedExternalMemberRolesNotification.cs b/src/Umbraco.Core/Notifications/AssignedExternalMemberRolesNotification.cs new file mode 100644 index 000000000000..81598e3f3a79 --- /dev/null +++ b/src/Umbraco.Core/Notifications/AssignedExternalMemberRolesNotification.cs @@ -0,0 +1,23 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is published after roles have been assigned to external members. +/// +/// +/// This notification is published by the when AssignRoles completes. +/// +public class AssignedExternalMemberRolesNotification : ExternalMemberRolesNotification +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique keys of the external members the roles are being assigned to. + /// The names of the roles being assigned. + public AssignedExternalMemberRolesNotification(Guid[] memberKeys, string[] roles) + : base(memberKeys, roles) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ExternalMemberCacheRefresherNotification.cs b/src/Umbraco.Core/Notifications/ExternalMemberCacheRefresherNotification.cs new file mode 100644 index 000000000000..a8cafecae3e1 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ExternalMemberCacheRefresherNotification.cs @@ -0,0 +1,26 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that triggers the external member cache refresher. +/// +/// +/// This notification is used to synchronize external member cache invalidation across +/// multiple servers in a load-balanced environment. +/// +public class ExternalMemberCacheRefresherNotification : CacheRefresherNotification +{ + /// + /// Initializes a new instance of the class. + /// + /// The payload containing information about the external member to refresh. + /// The type of cache refresh operation. + public ExternalMemberCacheRefresherNotification(object messageObject, MessageType messageType) + : base(messageObject, messageType) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ExternalMemberDeletedNotification.cs b/src/Umbraco.Core/Notifications/ExternalMemberDeletedNotification.cs new file mode 100644 index 000000000000..3b6b1f90b359 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ExternalMemberDeletedNotification.cs @@ -0,0 +1,27 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is published after an external member has been deleted. +/// +/// +/// This notification is published by the after the external member has been removed. +/// It is not cancelable since the delete operation has already completed. +/// +public sealed class ExternalMemberDeletedNotification : DeletedNotification +{ + /// + /// Initializes a new instance of the class with a single external member. + /// + /// The external member that was deleted. + /// The event messages collection. + public ExternalMemberDeletedNotification(ExternalMemberIdentity target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ExternalMemberDeletingNotification.cs b/src/Umbraco.Core/Notifications/ExternalMemberDeletingNotification.cs new file mode 100644 index 000000000000..7665cf01c98b --- /dev/null +++ b/src/Umbraco.Core/Notifications/ExternalMemberDeletingNotification.cs @@ -0,0 +1,37 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is published before an external member is deleted. +/// +/// +/// This notification is cancelable, allowing handlers to prevent the delete operation. +/// The notification is published by the before the external member is removed. +/// +public sealed class ExternalMemberDeletingNotification : DeletingNotification +{ + /// + /// Initializes a new instance of the class with a single external member. + /// + /// The external member being deleted. + /// The event messages collection. + public ExternalMemberDeletingNotification(ExternalMemberIdentity target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Initializes a new instance of the class with multiple external members. + /// + /// The collection of external members being deleted. + /// The event messages collection. + public ExternalMemberDeletingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ExternalMemberRolesNotification.cs b/src/Umbraco.Core/Notifications/ExternalMemberRolesNotification.cs new file mode 100644 index 000000000000..80c3e83129ad --- /dev/null +++ b/src/Umbraco.Core/Notifications/ExternalMemberRolesNotification.cs @@ -0,0 +1,34 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Abstract base class for notifications related to external member role assignments. +/// +/// +/// This class is used as a base for notifications published when external member roles are assigned or removed. +/// +public abstract class ExternalMemberRolesNotification : INotification +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique keys of the external members affected by the role change. + /// The names of the roles being assigned or removed. + protected ExternalMemberRolesNotification(Guid[] memberKeys, string[] roles) + { + MemberKeys = memberKeys; + Roles = roles; + } + + /// + /// Gets the unique keys of the external members affected by the role change. + /// + public Guid[] MemberKeys { get; } + + /// + /// Gets the names of the roles being assigned or removed. + /// + public string[] Roles { get; } +} diff --git a/src/Umbraco.Core/Notifications/ExternalMemberSavedNotification.cs b/src/Umbraco.Core/Notifications/ExternalMemberSavedNotification.cs new file mode 100644 index 000000000000..2ade4f57fbeb --- /dev/null +++ b/src/Umbraco.Core/Notifications/ExternalMemberSavedNotification.cs @@ -0,0 +1,37 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is published after an external member has been saved. +/// +/// +/// This notification is published by the after the external member has been persisted. +/// It is not cancelable since the save operation has already completed. +/// +public sealed class ExternalMemberSavedNotification : SavedNotification +{ + /// + /// Initializes a new instance of the class with a single external member. + /// + /// The external member that was saved. + /// The event messages collection. + public ExternalMemberSavedNotification(ExternalMemberIdentity target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Initializes a new instance of the class with multiple external members. + /// + /// The collection of external members that were saved. + /// The event messages collection. + public ExternalMemberSavedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ExternalMemberSavingNotification.cs b/src/Umbraco.Core/Notifications/ExternalMemberSavingNotification.cs new file mode 100644 index 000000000000..1a58ee274f8f --- /dev/null +++ b/src/Umbraco.Core/Notifications/ExternalMemberSavingNotification.cs @@ -0,0 +1,37 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is published before an external member is saved. +/// +/// +/// This notification is cancelable, allowing handlers to prevent the save operation. +/// The notification is published by the before the external member is persisted. +/// +public sealed class ExternalMemberSavingNotification : SavingNotification +{ + /// + /// Initializes a new instance of the class with a single external member. + /// + /// The external member being saved. + /// The event messages collection. + public ExternalMemberSavingNotification(ExternalMemberIdentity target, EventMessages messages) + : base(target, messages) + { + } + + /// + /// Initializes a new instance of the class with multiple external members. + /// + /// The collection of external members being saved. + /// The event messages collection. + public ExternalMemberSavingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/RemovedExternalMemberRolesNotification.cs b/src/Umbraco.Core/Notifications/RemovedExternalMemberRolesNotification.cs new file mode 100644 index 000000000000..f4ab684ec5bb --- /dev/null +++ b/src/Umbraco.Core/Notifications/RemovedExternalMemberRolesNotification.cs @@ -0,0 +1,23 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is published after roles have been removed from external members. +/// +/// +/// This notification is published by the when RemoveRoles completes. +/// +public class RemovedExternalMemberRolesNotification : ExternalMemberRolesNotification +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique keys of the external members the roles are being removed from. + /// The names of the roles being removed. + public RemovedExternalMemberRolesNotification(Guid[] memberKeys, string[] roles) + : base(memberKeys, roles) + { + } +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 6c98ebf475db..962157ef3ea2 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -299,6 +299,16 @@ public static class Tables /// public const string ExternalLoginToken = TableNamePrefix + "ExternalLoginToken"; + /// + /// The external member table name. + /// + public const string ExternalMember = TableNamePrefix + "ExternalMember"; + + /// + /// The external member to member group mapping table name. + /// + public const string ExternalMember2MemberGroup = TableNamePrefix + "ExternalMember2MemberGroup"; + /// /// The member table name. /// diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalMemberRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalMemberRepository.cs new file mode 100644 index 000000000000..ab476af4922f --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalMemberRepository.cs @@ -0,0 +1,93 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +/// +/// Defines the repository for external-only members that are not backed by the content system. +/// +public interface IExternalMemberRepository +{ + /// + /// Gets an external member by its unique key. + /// + /// The unique identifier of the external member. + /// The if found; otherwise null. + Task GetByKeyAsync(Guid key); + + /// + /// Gets an external member by email address. + /// + /// The email address to search for. + /// The if found; otherwise null. + Task GetByEmailAsync(string email); + + /// + /// Gets an external member by username. + /// + /// The username to search for. + /// The if found; otherwise null. + Task GetByUsernameAsync(string username); + + /// + /// Gets a paged collection of external members. + /// + /// The number of items to skip. + /// The number of items to take. + /// A containing the external members. + Task> GetPagedAsync(int skip, int take); + + /// + /// Creates a new external member in the database. + /// + /// The external member identity to create. + /// The database identity of the created external member. + Task CreateAsync(ExternalMemberIdentity member); + + /// + /// Updates an existing external member in the database. + /// + /// The external member identity to update. + Task UpdateAsync(ExternalMemberIdentity member); + + /// + /// Updates only the login-related properties of an external member. + /// + /// The external member identity carrying the new values. + /// + /// Sets lastLoginDate and securityStamp only. Deliberately does not + /// touch updateDate — login is not treated as a member update, and consequently the + /// member index is not refreshed. Does not perform uniqueness checks or full DTO mapping. + /// + Task UpdateLoginPropertiesAsync(ExternalMemberIdentity member); + + /// + /// Deletes an external member by its unique key. + /// + /// The unique key of the external member to delete. + Task DeleteAsync(Guid key); + + /// + /// Gets the role names assigned to an external member. + /// + /// The unique key of the external member. + /// A collection of role names. + Task> GetRolesAsync(Guid memberKey); + + /// + /// Assigns roles to an external member by database identities. + /// + /// The database identity of the external member. + /// The database identities of the member groups to assign. + Task AssignRolesAsync(int externalMemberId, int[] memberGroupIds); + + /// + /// Removes roles from an external member by database identities. + /// + /// The database identity of the external member. + /// The database identities of the member groups to remove. + Task RemoveRolesAsync(int externalMemberId, int[] memberGroupIds); +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IMemberFilterRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IMemberFilterRepository.cs new file mode 100644 index 000000000000..f42a6fe86644 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IMemberFilterRepository.cs @@ -0,0 +1,25 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +/// +/// Provides combined, paginated member queries across both the content member store +/// and the external member store. +/// +public interface IMemberFilterRepository +{ + /// + /// Gets a paged, filtered result of members from both the content and external member tables. + /// + /// The filter criteria. + /// The number of items to skip. + /// The number of items to return. + /// The ordering to apply. + /// A paged model of instances. + Task> GetPagedByFilterAsync(MemberFilter filter, int skip, int take, Ordering ordering); +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs index 92a1154b4102..9bfc415ef289 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs @@ -69,5 +69,11 @@ public interface IMemberRepository : IContentRepository /// /// The member to update. /// Used to avoid the full save of the member object after a login operation. + /// + /// Updates only the login-related columns (LastLoginDate, SecurityStampToken). + /// Deliberately does not touch UpdateDate or the corresponding + /// ContentVersionDto.VersionDate — those reflect real edits to the member, not login + /// activity. The member index is consequently not refreshed for login-only updates. + /// Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs index 95ae0d4dd13b..77918468c16c 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MemberPickerValueConverter.cs @@ -1,7 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Extensions; @@ -16,18 +19,35 @@ public class MemberPickerValueConverter : PropertyValueConverterBase, IDeliveryA { private readonly IMemberService _memberService; private readonly IPublishedMemberCache _memberCache; + private readonly IExternalMemberService _externalMemberService; + + /// + /// Initializes a new instance of the class. + /// + public MemberPickerValueConverter( + IMemberService memberService, + IPublishedMemberCache memberCache, + IExternalMemberService externalMemberService) + { + _memberService = memberService; + _memberCache = memberCache; + _externalMemberService = externalMemberService; + } /// /// Initializes a new instance of the class. /// /// The member service. /// The published member cache. + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] public MemberPickerValueConverter( IMemberService memberService, IPublishedMemberCache memberCache) + : this( + memberService, + memberCache, + StaticServiceProvider.Instance.GetRequiredService()) { - _memberService = memberService; - _memberCache = memberCache; } /// @@ -73,7 +93,6 @@ public override Type GetPropertyValueType(IPublishedPropertyType propertyType) return null; } - IPublishedContent? member; if (source is int id) { IMember? m = _memberService.GetById(id); @@ -82,7 +101,7 @@ public override Type GetPropertyValueType(IPublishedPropertyType propertyType) return null; } - member = _memberCache.Get(m); + IPublishedContent? member = _memberCache.Get(m); if (member != null) { return member; @@ -96,16 +115,21 @@ public override Type GetPropertyValueType(IPublishedPropertyType propertyType) } IMember? m = _memberService.GetById(sourceUdi.Guid); - if (m == null) + if (m != null) { - return null; + IPublishedContent? member = _memberCache.Get(m); + if (member != null) + { + return member; + } } - member = _memberCache.Get(m); - - if (member != null) + // Fall back to external member store. + ExternalMemberIdentity? external = _externalMemberService.GetByKeyAsync(sourceUdi.Guid) + .GetAwaiter().GetResult(); + if (external != null) { - return member; + return new PublishedExternalMember(external); } } diff --git a/src/Umbraco.Core/Security/ExternalMemberIdentity.cs b/src/Umbraco.Core/Security/ExternalMemberIdentity.cs new file mode 100644 index 000000000000..e643e8946b20 --- /dev/null +++ b/src/Umbraco.Core/Security/ExternalMemberIdentity.cs @@ -0,0 +1,85 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Security; + +/// +/// Represents a lightweight identity model for an external-only member +/// that is not backed by the content system. +/// +/// +/// External-only members are stored in the umbracoExternalMember table +/// and do not have content properties, content types, or tree structure. +/// Profile data is stored as a JSON string. +/// +public class ExternalMemberIdentity +{ + /// + /// Gets or sets the database identity of the external member. + /// + public int Id { get; set; } + + /// + /// Gets or sets the unique identifier key for the external member. + /// + public Guid Key { get; set; } = Guid.NewGuid(); + + /// + /// Gets or sets the email address of the external member. + /// + public string Email { get; set; } = string.Empty; + + /// + /// Gets or sets the username of the external member. + /// + public string UserName { get; set; } = string.Empty; + + /// + /// Gets or sets the display name of the external member. + /// + public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether the external member is approved. + /// + public bool IsApproved { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the external member is locked out. + /// + public bool IsLockedOut { get; set; } + + /// + /// Gets or sets the date and time of the last login. + /// + public DateTime? LastLoginDate { get; set; } + + /// + /// Gets or sets the date and time of the last lockout. + /// + public DateTime? LastLockoutDate { get; set; } + + /// + /// Gets or sets the date and time when the external member was created. + /// + public DateTime CreateDate { get; set; } + + /// + /// Gets or sets the date and time when the external member was last updated. + /// + /// + /// Reflects real edits to the member (name, email, profile data, etc.). Login operations + /// do not bump this value — login is not treated as a member update. + /// + public DateTime UpdateDate { get; set; } + + /// + /// Gets or sets the security stamp used for concurrency validation. + /// + public string? SecurityStamp { get; set; } + + /// + /// Gets or sets arbitrary profile data as a JSON string. + /// + public string? ProfileData { get; set; } +} diff --git a/src/Umbraco.Core/Security/PublishedExternalMember.cs b/src/Umbraco.Core/Security/PublishedExternalMember.cs new file mode 100644 index 000000000000..7f98de54c515 --- /dev/null +++ b/src/Umbraco.Core/Security/PublishedExternalMember.cs @@ -0,0 +1,278 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Text.Json; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Core.Security; + +/// +/// A lightweight representation for external-only members +/// that are not backed by the content system. +/// +/// +/// +/// External members have no content type, no content properties, no tree position, +/// and no template. This implementation provides sensible defaults for those members +/// so that member picker property values resolve correctly in templates. +/// +/// +/// If the external member has (a JSON string), +/// each top-level key in the JSON object is exposed as an . +/// This allows @Model.Member.Value("department") to work identically for both +/// content members (where "department" is a content property) and external members +/// (where "department" is a key in the profile data JSON). +/// +/// +public sealed class PublishedExternalMember : IPublishedMember +{ + private static readonly IPublishedContentType _externalMemberContentType = + new PublishedContentType( + Guid.Empty, + 0, + "ExternalMember", + PublishedItemType.Member, + [], + [], + ContentVariation.Nothing); + + private static readonly PublishedDataType _stringDataType = + new(0, Constants.PropertyEditors.Aliases.Label, null, new Lazy(() => null)); + + private readonly ExternalMemberIdentity _identity; + private readonly Lazy> _properties; + + /// + /// Initializes a new instance of the class. + /// + /// The external member identity to wrap. + public PublishedExternalMember(ExternalMemberIdentity identity) + { + _identity = identity; + _properties = new Lazy>(ParseProfileData); + } + + /// + public string Email => _identity.Email; + + /// + public string UserName => _identity.UserName; + + /// + public string? Comments => null; + + /// + public bool IsApproved => _identity.IsApproved; + + /// + public bool IsLockedOut => _identity.IsLockedOut; + + /// + public DateTime? LastLockoutDate => _identity.LastLockoutDate; + + /// + public DateTime CreationDate => _identity.CreateDate; + + /// + public DateTime? LastLoginDate => _identity.LastLoginDate; + + /// + public DateTime? LastPasswordChangedDate => null; + + /// + public int Id => _identity.Id; + + /// + public Guid Key => _identity.Key; + + /// + public string Name => _identity.Name ?? _identity.UserName; + + /// + public string? UrlSegment => null; + + /// + public int SortOrder => 0; + + /// + public int Level => 0; + + /// + public string Path => $"-1,{_identity.Id}"; + + /// + public int? TemplateId => null; + + /// + public int CreatorId => -1; + + /// + public DateTime CreateDate => _identity.CreateDate; + + /// + public int WriterId => -1; + + /// + public DateTime UpdateDate => _identity.CreateDate; + + /// + public IReadOnlyDictionary Cultures => new Dictionary(); + + /// + public PublishedItemType ItemType => PublishedItemType.Member; + +#pragma warning disable CS0618 // Type or member is obsolete + /// + public IPublishedContent? Parent => null; + + /// + public IEnumerable Children => []; +#pragma warning restore CS0618 + + /// + public bool IsDraft(string? culture = null) => false; + + /// + public bool IsPublished(string? culture = null) => true; + + /// + public IPublishedContentType ContentType => _externalMemberContentType; + + /// + public IEnumerable Properties => _properties.Value.Values; + + /// + public IPublishedProperty? GetProperty(string alias) + => _properties.Value.TryGetValue(alias, out IPublishedProperty? property) ? property : null; + + private IReadOnlyDictionary ParseProfileData() + { + if (string.IsNullOrWhiteSpace(_identity.ProfileData)) + { + return new Dictionary(); + } + + try + { + using JsonDocument doc = JsonDocument.Parse(_identity.ProfileData); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return new Dictionary(); + } + + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (JsonProperty jsonProperty in doc.RootElement.EnumerateObject()) + { + object? value = ConvertJsonElement(jsonProperty.Value); + properties[jsonProperty.Name] = new ProfileDataProperty(jsonProperty.Name, value); + } + + return properties; + } + catch (JsonException) + { + return new Dictionary(); + } + } + + private static object? ConvertJsonElement(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var l) => l, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + // For arrays and nested objects, return the raw JSON string. + _ => element.GetRawText(), + }; + + /// + /// A lightweight backed by a single value from profile data JSON. + /// + private sealed class ProfileDataProperty : IPublishedProperty + { + private readonly object? _value; + + public ProfileDataProperty(string alias, object? value) + { + Alias = alias; + _value = value; + PropertyType = new ProfileDataPropertyType(alias); + } + + /// + public IPublishedPropertyType PropertyType { get; } + + /// + public string Alias { get; } + + /// + public bool HasValue(string? culture = null, string? segment = null) => _value is not null; + + /// + public object? GetSourceValue(string? culture = null, string? segment = null) => _value; + + /// + public object? GetValue(string? culture = null, string? segment = null) => _value; + + /// + public object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) => _value; + } + + /// + /// A minimal for profile data properties. + /// + private sealed class ProfileDataPropertyType : IPublishedPropertyType + { + public ProfileDataPropertyType(string alias) => Alias = alias; + + /// + public IPublishedContentType? ContentType => _externalMemberContentType; + + /// + public PublishedDataType DataType => _stringDataType; + + /// + public string Alias { get; } + + /// + public string EditorAlias => Constants.PropertyEditors.Aliases.Label; + + /// + public string EditorUiAlias => Constants.PropertyEditors.Aliases.Label; + + /// + public bool IsUserProperty => true; + + /// + public ContentVariation Variations => ContentVariation.Nothing; + + /// + public PropertyCacheLevel CacheLevel => PropertyCacheLevel.None; + + /// + public PropertyCacheLevel DeliveryApiCacheLevel => PropertyCacheLevel.None; + + /// + public Type ModelClrType => typeof(object); + + /// + public Type? ClrType => typeof(object); + + /// + public bool? IsValue(object? value, PropertyValueLevel level) => value is not null; + + /// + public object? ConvertSourceToInter(IPublishedElement owner, object? source, bool preview) => source; + + /// + public object? ConvertInterToObject(IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) => inter; + + /// + public object? ConvertInterToDeliveryApiObject(IPublishedElement owner, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) => inter; + } +} diff --git a/src/Umbraco.Core/Services/ExternalMemberService.cs b/src/Umbraco.Core/Services/ExternalMemberService.cs new file mode 100644 index 000000000000..451f93cb1cf1 --- /dev/null +++ b/src/Umbraco.Core/Services/ExternalMemberService.cs @@ -0,0 +1,426 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Implements for managing external-only members +/// that are not backed by the content system. +/// +internal sealed class ExternalMemberService : RepositoryService, IExternalMemberService +{ + private readonly IExternalMemberRepository _repository; + private readonly IMemberService _memberService; + private readonly IMemberGroupService _memberGroupService; + private readonly IExternalLoginWithKeyRepository _externalLoginRepository; + private readonly IOptionsMonitor _securitySettings; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public ExternalMemberService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IExternalMemberRepository repository, + IMemberService memberService, + IMemberGroupService memberGroupService, + IExternalLoginWithKeyRepository externalLoginRepository, + IOptionsMonitor securitySettings) + : base(provider, loggerFactory, eventMessagesFactory) + { + _repository = repository; + _memberService = memberService; + _memberGroupService = memberGroupService; + _externalLoginRepository = externalLoginRepository; + _securitySettings = securitySettings; + _logger = loggerFactory.CreateLogger(); + } + + /// + public async Task GetByKeyAsync(Guid key) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetByKeyAsync(key); + } + + /// + public async Task GetByEmailAsync(string email) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetByEmailAsync(email); + } + + /// + public async Task GetByUsernameAsync(string username) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetByUsernameAsync(username); + } + + /// + public async Task> GetAllAsync(int skip, int take) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetPagedAsync(skip, take); + } + + /// + public async Task> CreateAsync(ExternalMemberIdentity member, IExternalLogin? externalLogin = null) + { + // Cross-store uniqueness checks run in a separate read-only scope so they don't hold + // SHARED table locks that would deadlock with concurrent write transactions on SQLite. + // The unique indexes on the table are the ultimate guard against duplicates. + using (ICoreScope readScope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + ExternalMemberOperationStatus? uniquenessResult = await ValidateUsernameUniqueAsync(member.UserName, null); + if (uniquenessResult is not null) + { + return Attempt.FailWithStatus(uniquenessResult.Value, member); + } + + if (_securitySettings.CurrentValue.MemberRequireUniqueEmail) + { + uniquenessResult = await ValidateEmailUniqueAsync(member.Email, null); + if (uniquenessResult is not null) + { + return Attempt.FailWithStatus(uniquenessResult.Value, member); + } + } + } + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + // Publish cancelable saving notification. + var savingNotification = new ExternalMemberSavingNotification(member, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.CancelledByNotification, member); + } + + DateTime now = DateTime.UtcNow; + if (member.CreateDate == default) + { + member.CreateDate = now; + } + + member.UpdateDate = now; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "External member {MemberKey} created via full path (indexing will occur).", + member.Key); + } + + // Persist. + var id = await _repository.CreateAsync(member); + member.Id = id; + + // Batched creation: save external login in the same scope if provided. + if (externalLogin is not null) + { + _externalLoginRepository.Save(member.Key, new[] { externalLogin }); + } + + // Publish saved notification. + scope.Notifications.Publish( + new ExternalMemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + + scope.Complete(); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, member); + } + + /// + public async Task> UpdateAsync(ExternalMemberIdentity member) + { + // Cross-store uniqueness checks run in a separate read-only scope so they don't hold + // SHARED table locks that would deadlock with concurrent write transactions on SQLite. + using (ICoreScope readScope = ScopeProvider.CreateCoreScope(autoComplete: true)) + { + ExternalMemberOperationStatus? uniquenessResult = await ValidateUsernameUniqueAsync(member.UserName, member.Key); + if (uniquenessResult is not null) + { + return Attempt.FailWithStatus(uniquenessResult.Value, member); + } + + if (_securitySettings.CurrentValue.MemberRequireUniqueEmail) + { + uniquenessResult = await ValidateEmailUniqueAsync(member.Email, member.Key); + if (uniquenessResult is not null) + { + return Attempt.FailWithStatus(uniquenessResult.Value, member); + } + } + } + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + // Publish cancelable saving notification. + var savingNotification = new ExternalMemberSavingNotification(member, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.CancelledByNotification, member); + } + + member.UpdateDate = DateTime.UtcNow; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "External member {MemberKey} updated via full path (indexing will occur).", + member.Key); + } + + await _repository.UpdateAsync(member); + + scope.Notifications.Publish( + new ExternalMemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + + scope.Complete(); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, member); + } + + /// + public async Task> UpdateLoginPropertiesAsync(ExternalMemberIdentity member) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "External member {MemberKey} login — lightweight update path (UpdateDate unchanged, no re-index).", + member.Key); + } + + // Login is not a member update: UpdateDate is intentionally left untouched, and the + // IndexableFieldsChanged state flag tells the Examine indexing handler to skip the + // re-index since no indexed field has changed. Mirror the content-member pattern + // (MemberService.UpdateLoginPropertiesAsync) so downstream handlers see the same shape. + var savingNotification = new ExternalMemberSavingNotification(member, evtMsgs); + savingNotification.State.Add(Constants.Conventions.Member.LoginPropertiesOnlyStateKey, true); + savingNotification.State.Add(Constants.Conventions.Member.IndexableFieldsChangedStateKey, false); + + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.CancelledByNotification, member); + } + + await _repository.UpdateLoginPropertiesAsync(member); + + scope.Notifications.Publish( + new ExternalMemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + + scope.Complete(); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, member); + } + + /// + public async Task> DeleteAsync(Guid key) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + ExternalMemberIdentity? member = await _repository.GetByKeyAsync(key); + if (member is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.NotFound, null); + } + + // Publish cancelable deleting notification. + var deletingNotification = new ExternalMemberDeletingNotification(member, evtMsgs); + if (scope.Notifications.PublishCancelable(deletingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.CancelledByNotification, member); + } + + // Delete external logins first. + _externalLoginRepository.DeleteUserLogins(key); + + await _repository.DeleteAsync(key); + + scope.Notifications.Publish( + new ExternalMemberDeletedNotification(member, evtMsgs)); + + scope.Complete(); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, member); + } + + /// + public async Task> GetRolesAsync(Guid memberKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetRolesAsync(memberKey); + } + + /// + public async Task> AssignRolesAsync(Guid memberKey, string[] roleNames) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + ExternalMemberIdentity? member = await _repository.GetByKeyAsync(memberKey); + if (member is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.NotFound, null); + } + + var groupIds = ResolveGroupIds(roleNames); + await _repository.AssignRolesAsync(member.Id, groupIds); + + scope.Notifications.Publish(new AssignedExternalMemberRolesNotification([memberKey], roleNames)); + + scope.Complete(); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, member); + } + + /// + public async Task> RemoveRolesAsync(Guid memberKey, string[] roleNames) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + ExternalMemberIdentity? member = await _repository.GetByKeyAsync(memberKey); + if (member is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.NotFound, null); + } + + var groupIds = ResolveGroupIds(roleNames); + await _repository.RemoveRolesAsync(member.Id, groupIds); + + scope.Notifications.Publish(new RemovedExternalMemberRolesNotification([memberKey], roleNames)); + + scope.Complete(); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, member); + } + + /// + public async Task> ConvertToContentMemberAsync(Guid memberKey, string memberTypeAlias, Action? mapProfileData = null) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + // Load the external member. + ExternalMemberIdentity? externalMember = await _repository.GetByKeyAsync(memberKey); + if (externalMember is null) + { + scope.Complete(); + return Attempt.FailWithStatus(ExternalMemberOperationStatus.NotFound, null); + } + + // Create the content member entity. + IMember contentMember = _memberService.CreateMember( + externalMember.UserName, + externalMember.Email, + externalMember.Name ?? externalMember.UserName, + memberTypeAlias); + + // Preserve the Guid key so external login links continue to resolve. + contentMember.Key = externalMember.Key; + contentMember.IsApproved = externalMember.IsApproved; + contentMember.IsLockedOut = externalMember.IsLockedOut; + + // Invalidate active sessions by setting a new security stamp. + contentMember.SecurityStamp = Guid.NewGuid().ToString(); + + // Allow the caller to map profileData fields to content properties before save. + mapProfileData?.Invoke(contentMember, externalMember.ProfileData); + + // Save the content member (this assigns the node ID). + _memberService.Save(contentMember); + + // Migrate group memberships: read external roles, assign to content member. + IEnumerable roles = await _repository.GetRolesAsync(externalMember.Key); + var roleNames = roles.ToArray(); + if (roleNames.Length > 0) + { + _memberService.AssignRoles([contentMember.Id], roleNames); + } + + // Delete the external member record and its group memberships. + await _repository.DeleteAsync(externalMember.Key); + + scope.Complete(); + + // Re-fetch to get the fully hydrated entity. + IMember? result = _memberService.GetById(contentMember.Key); + return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, result); + } + + private async Task ValidateUsernameUniqueAsync(string username, Guid? excludeKey) + { + // Check external store. + ExternalMemberIdentity? existingExternal = await _repository.GetByUsernameAsync(username); + if (existingExternal is not null && existingExternal.Key != excludeKey) + { + return ExternalMemberOperationStatus.DuplicateUsername; + } + + // Check content store. + IMember? existingContent = _memberService.GetByUsername(username); + if (existingContent is not null && existingContent.Key != excludeKey) + { + return ExternalMemberOperationStatus.DuplicateUsername; + } + + return null; + } + + private async Task ValidateEmailUniqueAsync(string email, Guid? excludeKey) + { + // Check external store. + ExternalMemberIdentity? existingExternal = await _repository.GetByEmailAsync(email); + if (existingExternal is not null && existingExternal.Key != excludeKey) + { + return ExternalMemberOperationStatus.DuplicateEmail; + } + + // Check content store. + IMember? existingContent = _memberService.GetByEmail(email); + if (existingContent is not null && existingContent.Key != excludeKey) + { + return ExternalMemberOperationStatus.DuplicateEmail; + } + + return null; + } + + private int[] ResolveGroupIds(string[] roleNames) + { + var ids = new List(roleNames.Length); + foreach (var roleName in roleNames) + { + IMemberGroup? group = _memberGroupService.GetByName(roleName); + if (group is not null) + { + ids.Add(group.Id); + } + } + + return ids.ToArray(); + } +} diff --git a/src/Umbraco.Core/Services/IExternalMemberService.cs b/src/Umbraco.Core/Services/IExternalMemberService.cs new file mode 100644 index 000000000000..05a047dea616 --- /dev/null +++ b/src/Umbraco.Core/Services/IExternalMemberService.cs @@ -0,0 +1,118 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Defines the ExternalMemberService, which provides operations for managing +/// external-only members that are not backed by the content system. +/// +public interface IExternalMemberService +{ + /// + /// Gets an external member by its unique key. + /// + /// The unique identifier of the external member. + /// The if found; otherwise null. + Task GetByKeyAsync(Guid key); + + /// + /// Gets an external member by email address. + /// + /// The email address to search for. + /// The if found; otherwise null. + Task GetByEmailAsync(string email); + + /// + /// Gets an external member by username. + /// + /// The username to search for. + /// The if found; otherwise null. + Task GetByUsernameAsync(string username); + + /// + /// Gets a paged collection of all external members. + /// + /// The number of items to skip. + /// The number of items to take. + /// A containing the external members. + Task> GetAllAsync(int skip, int take); + + /// + /// Creates a new external member. + /// + /// The external member identity to create. + /// An optional external login to associate with the member in the same transaction. + /// An with the created member on success. + Task> CreateAsync(ExternalMemberIdentity member, IExternalLogin? externalLogin = null); + + /// + /// Updates an existing external member. + /// + /// The external member identity to update. + /// An with the updated member on success. + Task> UpdateAsync(ExternalMemberIdentity member); + + /// + /// Updates only the login-related properties of an external member via a lightweight direct SQL update. + /// + /// The external member identity carrying the new values for LastLoginDate and SecurityStamp. + /// An with the updated member on success. + /// + /// Use this instead of on the login path when only LastLoginDate + /// and/or SecurityStamp have changed. Skips the uniqueness checks and full-DTO mapping + /// performed by the full update, and deliberately does not bump UpdateDate — + /// login is not treated as a member update, and the Examine index is not refreshed. Any + /// change to real member data (name, email, profile data, etc.) must go through + /// which does bump UpdateDate and triggers a re-index. + /// + Task> UpdateLoginPropertiesAsync(ExternalMemberIdentity member); + + /// + /// Deletes an external member by its unique key. + /// + /// The unique key of the external member to delete. + /// An with the deleted member on success, or a not-found status. + Task> DeleteAsync(Guid key); + + /// + /// Gets the role names assigned to an external member. + /// + /// The unique key of the external member. + /// A collection of role names. + Task> GetRolesAsync(Guid memberKey); + + /// + /// Assigns roles to an external member. + /// + /// The unique key of the external member. + /// The names of the roles to assign. + /// An indicating the operation result. + Task> AssignRolesAsync(Guid memberKey, string[] roleNames); + + /// + /// Removes roles from an external member. + /// + /// The unique key of the external member. + /// The names of the roles to remove. + /// An indicating the operation result. + Task> RemoveRolesAsync(Guid memberKey, string[] roleNames); + + /// + /// Converts an external-only member to a full content-based member. + /// + /// The unique key of the external member to convert. + /// The alias of the member type to use for the content-based member. + /// + /// An optional callback invoked after the content member is created but before it is saved. + /// Receives the new and the external member's profileData JSON string, + /// allowing the developer to map profile data fields to content properties + /// (e.g. member.SetValue("department", ...)). + /// + /// An with the newly created on success. + Task> ConvertToContentMemberAsync(Guid memberKey, string memberTypeAlias, Action? mapProfileData = null); +} diff --git a/src/Umbraco.Core/Services/IMemberEditingService.cs b/src/Umbraco.Core/Services/IMemberEditingService.cs index ed0cbe004c4d..9e362df507d9 100644 --- a/src/Umbraco.Core/Services/IMemberEditingService.cs +++ b/src/Umbraco.Core/Services/IMemberEditingService.cs @@ -1,6 +1,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -56,4 +57,20 @@ public interface IMemberEditingService /// The unique key of the user performing the delete operation. /// A task that represents the asynchronous operation. The task result contains an with the deleted member and status. Task> DeleteAsync(Guid key, Guid userKey); + + /// + /// Checks whether the specified key belongs to an external-only member. + /// + /// The unique key to check. + /// A task that represents the asynchronous operation. The task result is true if the key belongs to an external-only member; otherwise, false. + // TODO (V19): Remove the default implementation. + Task IsExternalMemberAsync(Guid key) => Task.FromResult(false); + + /// + /// Gets an external-only member by its unique key. + /// + /// The unique key of the external member. + /// A task that represents the asynchronous operation. The task result contains the if found; otherwise, null. + // TODO (V19): Remove the default implementation. + Task GetExternalMemberAsync(Guid key) => Task.FromResult(null); } diff --git a/src/Umbraco.Core/Services/IMemberFilterService.cs b/src/Umbraco.Core/Services/IMemberFilterService.cs new file mode 100644 index 000000000000..a690fb9d53ef --- /dev/null +++ b/src/Umbraco.Core/Services/IMemberFilterService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Provides filtered, paginated member listings that span both content-based and external-only members. +/// +public interface IMemberFilterService +{ + /// + /// Filters members across both content and external member stores, returning a unified paged result + /// ordered and paginated at the database level. + /// + /// The filter criteria. + /// The field to order by (e.g. "username", "email"). Defaults to "username". + /// The sort direction. + /// The number of items to skip. + /// The number of items to return. + /// A paged model of instances. + Task> FilterAsync( + MemberFilter filter, + string orderBy = "username", + Direction orderDirection = Direction.Ascending, + int skip = 0, + int take = 100); +} diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 7ff3514948f5..96b8591cba74 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -25,6 +25,7 @@ public class MemberService : RepositoryService, IMemberService private readonly IMemberGroupService _memberGroupService; private readonly Lazy _idKeyMap; private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly ILogger _logger; #region Constructor @@ -61,6 +62,7 @@ public MemberService( _idKeyMap = idKeyMap; _userIdKeyResolver = userIdKeyResolver; _memberGroupService = memberGroupService ?? throw new ArgumentNullException(nameof(memberGroupService)); + _logger = loggerFactory.CreateLogger(); } /// @@ -992,8 +994,20 @@ public async Task UpdateLoginPropertiesAsync(IMember member) EventMessages evtMsgs = EventMessagesFactory.Get(); using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Content member {MemberKey} login — lightweight update path (UpdateDate unchanged, no re-index).", + member.Key); + } + + // Login is not a member update: UpdateDate is intentionally left untouched, and the + // IndexableFieldsChanged state flag tells the Examine indexing handler to skip the + // re-index since no indexed field has changed. var savingNotification = new MemberSavingNotification(member, evtMsgs); - savingNotification.State.Add("LoginPropertiesOnly", true); + savingNotification.State.Add(Constants.Conventions.Member.LoginPropertiesOnlyStateKey, true); + savingNotification.State.Add(Constants.Conventions.Member.IndexableFieldsChangedStateKey, false); if (scope.Notifications.PublishCancelable(savingNotification)) { scope.Complete(); diff --git a/src/Umbraco.Core/Services/OperationStatus/ExternalMemberOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ExternalMemberOperationStatus.cs new file mode 100644 index 000000000000..77aa800fab9e --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/ExternalMemberOperationStatus.cs @@ -0,0 +1,40 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Core.Services.OperationStatus; + +/// +/// Represents the status of an external member operation. +/// +public enum ExternalMemberOperationStatus +{ + /// + /// The operation completed successfully. + /// + Success, + + /// + /// The operation failed because the external member could not be found. + /// + NotFound, + + /// + /// The operation was cancelled by a notification handler. + /// + CancelledByNotification, + + /// + /// The operation failed because a member with the same username already exists. + /// + DuplicateUsername, + + /// + /// The operation failed because a member with the same email already exists. + /// + DuplicateEmail, + + /// + /// The operation is not yet implemented. + /// + NotImplemented, +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a93e14e44f29..38d5ba25244c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -431,6 +431,8 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder) .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() @@ -464,7 +466,11 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder) .AddNotificationAsyncHandler() .AddNotificationAsyncHandler() .AddNotificationAsyncHandler() - .AddNotificationAsyncHandler(); + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler(); // Handlers for publish warnings builder.AddNotificationHandler(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs index b79eb025cb9e..1c028b72756e 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; @@ -37,6 +38,7 @@ public static IUmbracoBuilder AddExamine(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -71,6 +73,7 @@ public static IUmbracoBuilder AddExamine(this IUmbracoBuilder builder) factory.GetRequiredService())); builder.Services.AddUnique, MediaValueSetBuilder>(); builder.Services.AddUnique, MemberValueSetBuilder>(); + builder.Services.AddUnique, ExternalMemberValueSetBuilder>(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); @@ -86,6 +89,7 @@ public static IUmbracoBuilder AddExamine(this IUmbracoBuilder builder) builder.AddNotificationHandler(); builder.AddNotificationHandler(); builder.AddNotificationHandler(); + builder.AddNotificationHandler(); builder.AddNotificationAsyncHandler(); builder.AddNotificationHandler(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 4a7ca6d8366a..ce18049ec4ee 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -47,6 +47,8 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 5fa0248f95c4..6996745147f2 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -87,6 +87,7 @@ internal static IUmbracoBuilder AddServices(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); #pragma warning disable CS0618 // Type or member is obsolete diff --git a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs index 6c6e36d3d92e..6c5b6fffa957 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineUmbracoIndexingHandler.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Search; using Umbraco.Extensions; @@ -23,6 +24,7 @@ internal sealed class ExamineUmbracoIndexingHandler : IUmbracoIndexingHandler private readonly ILogger _logger; private readonly IValueSetBuilder _mediaValueSetBuilder; private readonly IValueSetBuilder _memberValueSetBuilder; + private readonly IValueSetBuilder _externalMemberValueSetBuilder; private readonly IPublishedContentValueSetBuilder _publishedContentValueSetBuilder; private readonly ICoreScopeProvider _scopeProvider; private readonly ExamineIndexingMainDomHandler _mainDomHandler; @@ -39,6 +41,7 @@ internal sealed class ExamineUmbracoIndexingHandler : IUmbracoIndexingHandler /// Builds value sets for published content items to be indexed. /// Builds value sets for media items to be indexed. /// Builds value sets for member items to be indexed. + /// Builds value sets for external member items to be indexed. /// Handles main domain (MainDom) events for Examine indexing. /// Provides services for managing public access to content. public ExamineUmbracoIndexingHandler( @@ -50,6 +53,7 @@ public ExamineUmbracoIndexingHandler( IPublishedContentValueSetBuilder publishedContentValueSetBuilder, IValueSetBuilder mediaValueSetBuilder, IValueSetBuilder memberValueSetBuilder, + IValueSetBuilder externalMemberValueSetBuilder, ExamineIndexingMainDomHandler mainDomHandler, IPublicAccessService publicAccessService) { @@ -61,6 +65,7 @@ public ExamineUmbracoIndexingHandler( _publishedContentValueSetBuilder = publishedContentValueSetBuilder; _mediaValueSetBuilder = mediaValueSetBuilder; _memberValueSetBuilder = memberValueSetBuilder; + _externalMemberValueSetBuilder = externalMemberValueSetBuilder; _mainDomHandler = mainDomHandler; _publicAccessService = publicAccessService; _enabled = new Lazy(IsEnabled); @@ -139,6 +144,31 @@ public void ReIndexForMember(IMember member) } } + /// + public void ReIndexForExternalMember(ExternalMemberIdentity member) + { + var actions = DeferredActions.Get(_scopeProvider); + if (actions != null) + { + actions.Add(new DeferredReIndexForExternalMember(_backgroundTaskQueue, this, member)); + } + else + { + DeferredReIndexForExternalMember.Execute(_backgroundTaskQueue, this, member); + } + } + + /// + public void DeleteExternalMemberFromIndex(int externalMemberId) + { + foreach (IUmbracoMemberIndex index in _examineManager.Indexes + .OfType() + .Where(x => x.EnableDefaultEventHandler)) + { + index.DeleteFromIndex(externalMemberId.ToString(CultureInfo.InvariantCulture)); + } + } + /// public void RemoveProtectedContent() { @@ -433,6 +463,54 @@ public static void Execute( }); } + /// + /// Re-indexes an item on a background thread + /// + private sealed class DeferredReIndexForExternalMember : IDeferredAction + { + private readonly IBackgroundTaskQueue _backgroundTaskQueue; + private readonly ExamineUmbracoIndexingHandler _examineUmbracoIndexingHandler; + private readonly ExternalMemberIdentity _member; + + public DeferredReIndexForExternalMember( + IBackgroundTaskQueue backgroundTaskQueue, + ExamineUmbracoIndexingHandler examineUmbracoIndexingHandler, + ExternalMemberIdentity member) + { + _examineUmbracoIndexingHandler = examineUmbracoIndexingHandler; + _member = member; + _backgroundTaskQueue = backgroundTaskQueue; + } + + public void Execute() => Execute(_backgroundTaskQueue, _examineUmbracoIndexingHandler, _member); + + public static void Execute( + IBackgroundTaskQueue backgroundTaskQueue, + ExamineUmbracoIndexingHandler examineUmbracoIndexingHandler, + ExternalMemberIdentity member) => + backgroundTaskQueue.QueueBackgroundWorkItem(cancellationToken => + { + using ICoreScope scope = + examineUmbracoIndexingHandler._scopeProvider.CreateCoreScope(autoComplete: true); + + var valueSet = examineUmbracoIndexingHandler._externalMemberValueSetBuilder.GetValueSets(member).ToList(); + + foreach (IUmbracoIndex index in examineUmbracoIndexingHandler._examineManager.Indexes + .OfType() + .Where(x => x.EnableDefaultEventHandler)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.CompletedTask; + } + + index.IndexItems(valueSet); + } + + return Task.CompletedTask; + }); + } + private sealed class DeferredDeleteIndex : IDeferredAction { private readonly ExamineUmbracoIndexingHandler _examineUmbracoIndexingHandler; diff --git a/src/Umbraco.Infrastructure/Examine/ExternalMemberIndexPopulator.cs b/src/Umbraco.Infrastructure/Examine/ExternalMemberIndexPopulator.cs new file mode 100644 index 000000000000..7742628a68f8 --- /dev/null +++ b/src/Umbraco.Infrastructure/Examine/ExternalMemberIndexPopulator.cs @@ -0,0 +1,64 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Examine; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Examine; + +/// +/// Populates and maintains the member index with external-only member data +/// used by the Examine search engine in Umbraco. +/// +/// +/// This populator pages through all external members via +/// and indexes them into any registered . +/// +public class ExternalMemberIndexPopulator : IndexPopulator +{ + private readonly IExternalMemberService _externalMemberService; + private readonly IValueSetBuilder _valueSetBuilder; + + /// + /// Initializes a new instance of the class. + /// + /// Service for accessing external-only member data. + /// Builder for creating value sets from external member entities. + public ExternalMemberIndexPopulator( + IExternalMemberService externalMemberService, + IValueSetBuilder valueSetBuilder) + { + _externalMemberService = externalMemberService; + _valueSetBuilder = valueSetBuilder; + } + + /// + protected override void PopulateIndexes(IReadOnlyList indexes) + { + if (indexes.Count == 0) + { + return; + } + + const int pageSize = 1000; + var skip = 0; + + ExternalMemberIdentity[] members; + + do + { + members = _externalMemberService.GetAllAsync(skip, pageSize) + .GetAwaiter().GetResult() + .Items.ToArray(); + + foreach (IIndex index in indexes) + { + index.IndexItems(_valueSetBuilder.GetValueSets(members)); + } + + skip += pageSize; + } + while (members.Length == pageSize); + } +} diff --git a/src/Umbraco.Infrastructure/Examine/ExternalMemberValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/ExternalMemberValueSetBuilder.cs new file mode 100644 index 000000000000..6f9144e36d1e --- /dev/null +++ b/src/Umbraco.Infrastructure/Examine/ExternalMemberValueSetBuilder.cs @@ -0,0 +1,98 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Text.Json; +using Examine; +using Umbraco.Cms.Core.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Examine; + +/// +/// Builds instances for external-only members so they can be indexed by Examine. +/// +/// +/// +/// External-only members do not have content properties or content types, +/// so this builder produces a fixed set of fields from the model. +/// +/// +/// If the member has , each top-level key in +/// the JSON object is indexed as an additional field. This allows profile data to be searchable +/// via Examine alongside the standard identity fields. +/// +/// +public class ExternalMemberValueSetBuilder : IValueSetBuilder +{ + /// + public IEnumerable GetValueSets(params ExternalMemberIdentity[] members) + { + foreach (ExternalMemberIdentity member in members) + { + var values = new Dictionary> + { + { UmbracoExamineFieldNames.NodeKeyFieldName, new object[] { member.Key } }, + { UmbracoExamineFieldNames.NodeNameFieldName, member.Name?.Yield() ?? Enumerable.Empty() }, + { "loginName", member.UserName.Yield() }, + { "email", member.Email.Yield() }, + { "createDate", new object[] { member.CreateDate } }, + { "updateDate", new object[] { member.UpdateDate } }, + { "id", new object[] { member.Id } }, + { "isExternalOnly", "1".Yield() }, + }; + + AddProfileDataFields(values, member.ProfileData); + + var vs = new ValueSet(member.Id.ToInvariantString(), IndexTypes.Member, "ExternalMember", values); + + yield return vs; + } + } + + private static void AddProfileDataFields(Dictionary> values, string? profileData) + { + if (string.IsNullOrWhiteSpace(profileData)) + { + return; + } + + try + { + using JsonDocument doc = JsonDocument.Parse(profileData); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (JsonProperty property in doc.RootElement.EnumerateObject()) + { + // Skip if this would collide with a built-in field. + if (values.ContainsKey(property.Name)) + { + continue; + } + + var fieldValue = ConvertJsonElement(property.Value); + if (fieldValue is not null) + { + values[property.Name] = new object[] { fieldValue }; + } + } + } + catch (JsonException) + { + // Invalid JSON — skip profile data indexing silently. + } + } + + private static object? ConvertJsonElement(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var l) => l, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, // Null, arrays, and nested objects are not indexed. + }; +} diff --git a/src/Umbraco.Infrastructure/Examine/MemberValueSetValidator.cs b/src/Umbraco.Infrastructure/Examine/MemberValueSetValidator.cs index 19f57e41d58e..4d31ce73e553 100644 --- a/src/Umbraco.Infrastructure/Examine/MemberValueSetValidator.cs +++ b/src/Umbraco.Infrastructure/Examine/MemberValueSetValidator.cs @@ -11,7 +11,7 @@ public class MemberValueSetValidator : ValueSetValidator public static readonly string[] DefaultMemberIndexFields = { "id", UmbracoExamineFieldNames.NodeNameFieldName, "updateDate", "loginName", "email", - UmbracoExamineFieldNames.NodeKeyFieldName, + UmbracoExamineFieldNames.NodeKeyFieldName, "createDate", "isExternalOnly", }; private static readonly IEnumerable _validCategories = new[] { IndexTypes.Member }; @@ -19,8 +19,13 @@ public class MemberValueSetValidator : ValueSetValidator /// /// Initializes a new instance of the class, which is used to validate value sets for members in Examine. /// + /// + /// The default constructor does not restrict indexed fields (passes null for includeFields) + /// so that external member profile data fields are indexed alongside the standard identity fields. + /// The value set builders already control which fields are emitted. + /// public MemberValueSetValidator() - : base(null, null, DefaultMemberIndexFields, null) + : base(null, null, null, null) { } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index f8d979ea358e..80509cde5769 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -45,6 +45,8 @@ public class DatabaseSchemaCreator typeof(MemberPropertyTypeDto), typeof(MemberDto), typeof(Member2MemberGroupDto), + typeof(ExternalMemberDto), + typeof(ExternalMember2MemberGroupDto), typeof(PropertyTypeGroupDto), typeof(PropertyTypeDto), typeof(PropertyDataDto), diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 6da65bcadf31..7b7dbfaab4eb 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -170,6 +170,7 @@ protected virtual void DefinePlan() // To 17.4.0 To("{D4E5F6A7-B8C9-4D0E-A1F2-3B4C5D6E7F80}"); To("{72970B86-59D8-403C-B322-FFF43F9DB199}"); + To("{D7E8F9A0-B1C2-4D3E-A5F6-7890ABCDEF12}"); // To 18.0.0 // TODO (V18): Enable on 18 branch diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_4_0/AddExternalMemberTables.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_4_0/AddExternalMemberTables.cs new file mode 100644 index 000000000000..6913a4d24aef --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_4_0/AddExternalMemberTables.cs @@ -0,0 +1,36 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_4_0; + +/// +/// Migration to add the external member and external member to member group tables. +/// +public class AddExternalMemberTables : AsyncMigrationBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The migration context. + public AddExternalMemberTables(IMigrationContext context) + : base(context) + { + } + + /// + protected override async Task MigrateAsync() + { + if (TableExists(Constants.DatabaseSchema.Tables.ExternalMember) is false) + { + Create.Table().Do(); + } + + if (TableExists(Constants.DatabaseSchema.Tables.ExternalMember2MemberGroup) is false) + { + Create.Table().Do(); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalMember2MemberGroupDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalMember2MemberGroupDto.cs new file mode 100644 index 000000000000..6f528f9fd5c3 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalMember2MemberGroupDto.cs @@ -0,0 +1,34 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey([ExternalMemberColumnName, MemberGroupColumnName], AutoIncrement = false)] +[ExplicitColumns] +internal sealed class ExternalMember2MemberGroupDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.ExternalMember2MemberGroup; + public const string ExternalMemberColumnName = "externalMemberId"; + + private const string MemberGroupColumnName = "memberGroupId"; + + /// + /// Gets or sets the identifier of the external member. + /// + [Column(ExternalMemberColumnName)] + [PrimaryKeyColumn(AutoIncrement = false, Name = "PK_" + TableName, OnColumns = $"{ExternalMemberColumnName}, {MemberGroupColumnName}")] + [ForeignKey(typeof(ExternalMemberDto))] + public int ExternalMemberId { get; set; } + + /// + /// Gets or sets the identifier of the member group. + /// + [Column(MemberGroupColumnName)] + [ForeignKey(typeof(NodeDto))] + public int MemberGroupId { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalMemberDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalMemberDto.cs new file mode 100644 index 000000000000..ab7d890aa87f --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalMemberDto.cs @@ -0,0 +1,116 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[ExplicitColumns] +[PrimaryKey(PrimaryKeyColumnName)] +internal sealed class ExternalMemberDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.ExternalMember; + public const string PrimaryKeyColumnName = "id"; + + /// + /// Gets or sets the unique identifier for the external member. + /// + [Column(PrimaryKeyColumnName)] + [PrimaryKeyColumn] + public int Id { get; set; } + + /// + /// Gets or sets the unique key for the external member. + /// + [Column("key")] + [Index(IndexTypes.UniqueNonClustered, Name = "IX_" + TableName + "_Key")] + public Guid Key { get; set; } + + /// + /// Gets or sets the email address of the external member. + /// + [Column("email")] + [Length(1000)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_Email")] + public string Email { get; set; } = null!; + + /// + /// Gets or sets the username of the external member. + /// + [Column("userName")] + [Length(1000)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_UserName")] + public string UserName { get; set; } = null!; + + /// + /// Gets or sets the display name of the external member. + /// + [Column("name")] + [Length(1000)] + [NullSetting(NullSetting = NullSettings.Null)] + public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether the external member is approved. + /// + [Column("isApproved")] + [Constraint(Default = "1")] + public bool IsApproved { get; set; } + + /// + /// Gets or sets a value indicating whether the external member is locked out. + /// + [Column("isLockedOut")] + [Constraint(Default = "0")] + public bool IsLockedOut { get; set; } + + /// + /// Gets or sets the date and time of the last login. + /// + [Column("lastLoginDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastLoginDate { get; set; } + + /// + /// Gets or sets the date and time of the last lockout. + /// + [Column("lastLockoutDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastLockoutDate { get; set; } + + /// + /// Gets or sets the date and time when the external member was created. + /// + [Column("createDate")] + [Constraint(Default = SystemMethods.CurrentUTCDateTime)] + public DateTime CreateDate { get; set; } + + /// + /// Gets or sets the date and time when the external member was last updated. + /// + [Column("updateDate")] + [Constraint(Default = SystemMethods.CurrentUTCDateTime)] + public DateTime UpdateDate { get; set; } + + /// + /// Gets or sets the security stamp for the external member. + /// + [Column("securityStamp")] + [Length(255)] + [NullSetting(NullSetting = NullSettings.Null)] + public string? SecurityStamp { get; set; } + + /// + /// Gets or sets the serialized profile data for the external member. + /// + [Column("profileData")] + [NullSetting(NullSetting = NullSettings.Null)] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] + public string? ProfileData { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs index 2b56c970417f..cbe6acb0216e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs @@ -74,10 +74,14 @@ public int Count(IQuery? query) /// public void DeleteUserLogins(Guid userOrMemberKey) { - Sql sql = SqlContext.Sql() - .Delete() + // Find login IDs first, then use the shared helper that deletes tokens before logins. + Sql sql = Sql() + .Select(x => x.Id) + .From() .Where(x => x.UserOrMemberKey == userOrMemberKey); - Database.Execute(sql); + + var loginIds = Database.Query(sql).Select(x => x.Id).ToList(); + DeleteExternalLogins(loginIds); } /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalMemberRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalMemberRepository.cs new file mode 100644 index 000000000000..f06fac2ffaca --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalMemberRepository.cs @@ -0,0 +1,227 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +/// +/// Implements persistence operations for external-only members that are not backed by the content system. +/// +internal sealed class ExternalMemberRepository : IExternalMemberRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + /// + /// Initializes a new instance of the class. + /// + public ExternalMemberRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database => + _scopeAccessor.AmbientScope?.Database + ?? throw new NotSupportedException("Need to be executed in a scope."); + + private ISqlContext SqlContext => + _scopeAccessor.AmbientScope?.SqlContext + ?? throw new NotSupportedException("Need to be executed in a scope."); + + /// + public async Task GetByKeyAsync(Guid key) + { + Sql sql = SqlContext.Sql() + .Select() + .From() + .Where(x => x.Key == key); + + ExternalMemberDto? dto = await Database.FirstOrDefaultAsync(sql); + return dto is null ? null : MapToIdentity(dto); + } + + /// + public async Task GetByEmailAsync(string email) + { + Sql sql = SqlContext.Sql() + .Select() + .From() + .Where(x => x.Email == email); + + ExternalMemberDto? dto = await Database.FirstOrDefaultAsync(sql); + return dto is null ? null : MapToIdentity(dto); + } + + /// + public async Task GetByUsernameAsync(string username) + { + Sql sql = SqlContext.Sql() + .Select() + .From() + .Where(x => x.UserName == username); + + ExternalMemberDto? dto = await Database.FirstOrDefaultAsync(sql); + return dto is null ? null : MapToIdentity(dto); + } + + /// + public async Task> GetPagedAsync(int skip, int take) + { + Sql sql = SqlContext.Sql() + .Select() + .From() + .OrderBy(x => x.UserName); + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + Page page = await Database.PageAsync(pageNumber + 1, pageSize, sql); + + return new PagedModel + { + Total = page.TotalItems, + Items = page.Items.Select(MapToIdentity), + }; + } + + /// + public async Task CreateAsync(ExternalMemberIdentity member) + { + ExternalMemberDto dto = MapToDto(member); + var result = await Database.InsertAsync(dto); + var id = Convert.ToInt32(result); + member.Id = id; + return id; + } + + /// + public async Task UpdateAsync(ExternalMemberIdentity member) + { + ExternalMemberDto dto = MapToDto(member); + await Database.UpdateAsync(dto); + } + + /// + public async Task UpdateLoginPropertiesAsync(ExternalMemberIdentity member) + { + NPocoSqlExtensions.SqlUpd GetSetExpression(NPocoSqlExtensions.SqlUpd _) + { + var setExpression = new NPocoSqlExtensions.SqlUpd(SqlContext); + setExpression.Set(x => x.LastLoginDate, member.LastLoginDate); + setExpression.Set(x => x.SecurityStamp, member.SecurityStamp); + return setExpression; + } + + // Login is not a member update — updateDate is intentionally left untouched. + Sql sql = SqlContext.Sql() + .Update(GetSetExpression) + .Where(x => x.Key == member.Key); + + await Database.ExecuteAsync(sql); + } + + /// + public async Task DeleteAsync(Guid key) + { + // Delete group memberships first (FK constraint). + ExternalMemberDto? dto = await Database.FirstOrDefaultAsync( + SqlContext.Sql() + .Select() + .From() + .Where(x => x.Key == key)); + + if (dto is null) + { + return; + } + + Database.DeleteMany() + .Where(x => x.ExternalMemberId == dto.Id) + .Execute(); + + await Database.DeleteAsync(dto); + } + + /// + public async Task> GetRolesAsync(Guid memberKey) + { + Sql sql = SqlContext.Sql() + .Select(x => x.Text) + .From() + .InnerJoin() + .On( + left => left.MemberGroupId, right => right.NodeId) + .InnerJoin() + .On( + left => left.ExternalMemberId, right => right.Id) + .Where(x => x.Key == memberKey) + .Where(x => x.NodeObjectType == Constants.ObjectTypes.MemberGroup); + + List nodes = await Database.FetchAsync(sql); + + return nodes + .Where(n => n.Text is not null) + .Select(n => n.Text!); + } + + /// + public async Task AssignRolesAsync(int externalMemberId, int[] memberGroupIds) + { + IEnumerable dtos = memberGroupIds.Select(groupId => + new ExternalMember2MemberGroupDto + { + ExternalMemberId = externalMemberId, + MemberGroupId = groupId, + }); + + await Database.InsertBulkAsync(dtos); + } + + /// + public async Task RemoveRolesAsync(int externalMemberId, int[] memberGroupIds) + { + Database.DeleteMany() + .Where(x => x.ExternalMemberId == externalMemberId && memberGroupIds.Contains(x.MemberGroupId)) + .Execute(); + } + + private static ExternalMemberIdentity MapToIdentity(ExternalMemberDto dto) => + new() + { + Id = dto.Id, + Key = dto.Key, + Email = dto.Email, + UserName = dto.UserName, + Name = dto.Name, + IsApproved = dto.IsApproved, + IsLockedOut = dto.IsLockedOut, + LastLoginDate = dto.LastLoginDate, + LastLockoutDate = dto.LastLockoutDate, + CreateDate = dto.CreateDate, + UpdateDate = dto.UpdateDate, + SecurityStamp = dto.SecurityStamp, + ProfileData = dto.ProfileData, + }; + + private static ExternalMemberDto MapToDto(ExternalMemberIdentity identity) => + new() + { + Id = identity.Id, + Key = identity.Key, + Email = identity.Email, + UserName = identity.UserName, + Name = identity.Name, + IsApproved = identity.IsApproved, + IsLockedOut = identity.IsLockedOut, + LastLoginDate = identity.LastLoginDate, + LastLockoutDate = identity.LastLockoutDate, + CreateDate = identity.CreateDate, + UpdateDate = identity.UpdateDate, + SecurityStamp = identity.SecurityStamp, + ProfileData = identity.ProfileData, + }; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberFilterRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberFilterRepository.cs new file mode 100644 index 000000000000..e4ed8dd9de88 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberFilterRepository.cs @@ -0,0 +1,264 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +/// +/// Provides combined, paginated member queries across both the content member store +/// and the external member store using a UNION query. +/// +internal sealed class MemberFilterRepository : IMemberFilterRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// The scope accessor. + public MemberFilterRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database => + _scopeAccessor.AmbientScope?.Database + ?? throw new NotSupportedException("Need to be executed in a scope."); + + private ISqlContext SqlContext => + _scopeAccessor.AmbientScope?.SqlContext + ?? throw new NotSupportedException("Need to be executed in a scope."); + + /// + public async Task> GetPagedByFilterAsync(MemberFilter filter, int skip, int take, Ordering ordering) + { + // Build the content members SELECT. + Sql contentSql = BuildContentMemberSql(filter); + + // Build the external members SELECT (excluded when filtering by memberTypeId since external members have no type). + Sql? externalSql = filter.MemberTypeId.HasValue ? null : BuildExternalMemberSql(filter); + + // Combine with UNION ALL inside a subquery for unified ordering and paging. + Sql combinedSql; + if (externalSql is not null) + { + combinedSql = SqlContext.Sql() + .Append("SELECT * FROM (") + .Append(contentSql) + .Append("UNION ALL") + .Append(externalSql) + .Append(") AS combined"); + } + else + { + combinedSql = SqlContext.Sql() + .Append("SELECT * FROM (") + .Append(contentSql) + .Append(") AS combined"); + } + + // Apply ordering. + var orderColumn = MapOrderByColumn(ordering.OrderBy); + combinedSql = ordering.Direction == Direction.Ascending + ? combinedSql.Append($"ORDER BY {orderColumn} ASC") + : combinedSql.Append($"ORDER BY {orderColumn} DESC"); + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + Page page = await Database.PageAsync(pageNumber + 1, pageSize, combinedSql); + + return new PagedModel( + page.TotalItems, + page.Items.Select(MapToItem)); + } + + private Sql BuildContentMemberSql(MemberFilter filter) + { + Sql sql = SqlContext.Sql() + .Append($@"SELECT + n.[uniqueId] AS [Key], + m.[Email], + m.[LoginName] AS [UserName], + n.[text] AS [Name], + m.[IsApproved], + m.[IsLockedOut], + m.[LastLoginDate], + m.[LastLockoutDate], + m.[LastPasswordChangeDate], + CAST(0 AS bit) AS [IsExternalOnly], + ctn.[uniqueId] AS [MemberTypeKey], + ctn.[text] AS [MemberTypeName], + ctd.[icon] AS [MemberTypeIcon] + FROM [{Constants.DatabaseSchema.Tables.Member}] m + INNER JOIN [{Constants.DatabaseSchema.Tables.Node}] n ON n.[id] = m.[nodeId] + INNER JOIN [{Constants.DatabaseSchema.Tables.Content}] ct ON ct.[nodeId] = n.[id] + INNER JOIN [{Constants.DatabaseSchema.Tables.Node}] ctn ON ctn.[id] = ct.[contentTypeId] + INNER JOIN [cmsContentType] ctd ON ctd.[nodeId] = ctn.[id]"); + + // Append optional JOINs before any WHERE clauses. + if (filter.MemberGroupName.IsNullOrWhiteSpace() is false) + { + sql = sql.Append( + $@"INNER JOIN [{Constants.DatabaseSchema.Tables.Member2MemberGroup}] m2mg ON m2mg.[{Member2MemberGroupDto.MemberColumnName}] = m.[nodeId] + INNER JOIN [{Constants.DatabaseSchema.Tables.Node}] mgn ON mgn.[id] = m2mg.[MemberGroup] AND mgn.[text] = @groupName", new { groupName = filter.MemberGroupName }); + } + + if (filter.MemberTypeId.HasValue) + { + sql = sql.Append("WHERE ctn.[uniqueId] = @typeId", new { typeId = filter.MemberTypeId.Value }); + } + + AppendWhereFilters(ref sql, filter, "m.[Email]", "m.[LoginName]", "n.[text]", "m.[IsApproved]", "m.[IsLockedOut]", filter.MemberTypeId.HasValue); + + return sql; + } + + private Sql BuildExternalMemberSql(MemberFilter filter) + { + Sql sql = SqlContext.Sql() + .Append($@"SELECT + em.[key] AS [Key], + em.[email] AS [Email], + em.[userName] AS [UserName], + em.[name] AS [Name], + em.[isApproved] AS [IsApproved], + em.[isLockedOut] AS [IsLockedOut], + em.[lastLoginDate] AS [LastLoginDate], + em.[lastLockoutDate] AS [LastLockoutDate], + CAST(NULL AS datetime) AS [LastPasswordChangeDate], + CAST(1 AS bit) AS [IsExternalOnly], + CAST(NULL AS uniqueidentifier) AS [MemberTypeKey], + CAST(NULL AS nvarchar(255)) AS [MemberTypeName], + CAST(NULL AS nvarchar(255)) AS [MemberTypeIcon] + FROM [{Constants.DatabaseSchema.Tables.ExternalMember}] em"); + + if (filter.MemberGroupName.IsNullOrWhiteSpace() is false) + { + sql = sql.Append( + $@"INNER JOIN [{Constants.DatabaseSchema.Tables.ExternalMember2MemberGroup}] em2mg ON em2mg.[externalMemberId] = em.[id] + INNER JOIN [{Constants.DatabaseSchema.Tables.Node}] emgn ON emgn.[id] = em2mg.[memberGroupId] AND emgn.[text] = @groupName", new { groupName = filter.MemberGroupName }); + } + + AppendWhereFilters(ref sql, filter, "em.[email]", "em.[userName]", "em.[name]", "em.[isApproved]", "em.[isLockedOut]", hasWhereAlready: false); + + return sql; + } + + private static void AppendWhereFilters( + ref Sql sql, + MemberFilter filter, + string emailCol, + string usernameCol, + string nameCol, + string isApprovedCol, + string isLockedOutCol, + bool hasWhereAlready) + { + var hasWhere = hasWhereAlready; + + if (filter.IsApproved is not null) + { + sql = hasWhere + ? sql.Append($"AND {isApprovedCol} = @approved", new { approved = filter.IsApproved.Value }) + : sql.Append($"WHERE {isApprovedCol} = @approved", new { approved = filter.IsApproved.Value }); + hasWhere = true; + } + + if (filter.IsLockedOut is not null) + { + sql = hasWhere + ? sql.Append($"AND {isLockedOutCol} = @lockedOut", new { lockedOut = filter.IsLockedOut.Value }) + : sql.Append($"WHERE {isLockedOutCol} = @lockedOut", new { lockedOut = filter.IsLockedOut.Value }); + hasWhere = true; + } + + if (filter.Filter.IsNullOrWhiteSpace() is false) + { + var keyword = hasWhere ? "AND" : "WHERE"; + sql = sql.Append( + $"{keyword} ({emailCol} LIKE @filterParam OR {usernameCol} LIKE @filterParam OR {nameCol} LIKE @filterParam)", + new { filterParam = $"%{filter.Filter}%" }); + } + } + + private static string MapOrderByColumn(string? orderBy) => + orderBy?.ToLowerInvariant() switch + { + "email" => "[Email]", + "name" => "[Name]", + "isapproved" => "[IsApproved]", + "islockedout" => "[IsLockedOut]", + "lastlogindate" => "[LastLoginDate]", + _ => "[UserName]", + }; + + private static MemberFilterItem MapToItem(MemberFilterItemDto dto) => + new() + { + Key = dto.Key, + Email = dto.Email, + UserName = dto.UserName, + Name = dto.Name, + IsApproved = dto.IsApproved, + IsLockedOut = dto.IsLockedOut, + LastLoginDate = dto.LastLoginDate, + LastLockoutDate = dto.LastLockoutDate, + LastPasswordChangeDate = dto.LastPasswordChangeDate, + IsExternalOnly = dto.IsExternalOnly, + Kind = dto.IsExternalOnly ? MemberKind.ExternalOnly : MemberKind.Default, + MemberTypeKey = dto.MemberTypeKey, + MemberTypeName = dto.MemberTypeName, + MemberTypeIcon = dto.MemberTypeIcon, + }; + + /// + /// Internal DTO for the UNION query result rows. + /// + [ExplicitColumns] + private sealed class MemberFilterItemDto + { + [Column("Key")] + public Guid Key { get; set; } + + [Column("Email")] + public string Email { get; set; } = string.Empty; + + [Column("UserName")] + public string UserName { get; set; } = string.Empty; + + [Column("Name")] + public string? Name { get; set; } + + [Column("IsApproved")] + public bool IsApproved { get; set; } + + [Column("IsLockedOut")] + public bool IsLockedOut { get; set; } + + [Column("LastLoginDate")] + public DateTime? LastLoginDate { get; set; } + + [Column("LastLockoutDate")] + public DateTime? LastLockoutDate { get; set; } + + [Column("LastPasswordChangeDate")] + public DateTime? LastPasswordChangeDate { get; set; } + + [Column("IsExternalOnly")] + public bool IsExternalOnly { get; set; } + + [Column("MemberTypeKey")] + public Guid? MemberTypeKey { get; set; } + + [Column("MemberTypeName")] + public string? MemberTypeName { get; set; } + + [Column("MemberTypeIcon")] + public string? MemberTypeIcon { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs index e7394552db4b..b3b4d10d71b9 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs @@ -1185,18 +1185,14 @@ NPocoSqlExtensions.SqlUpd GetMemberSetExpression(IMember member, NPoc return setExpression; } - member.UpdatingEntity(); - + // Login is not considered a member update: neither UpdateDate nor the associated + // ContentVersionDto.VersionDate is touched. Any change to actual member data (name, email, + // properties, etc.) goes through the full Save path which does bump both. Sql updateMemberQuery = Sql() .Update(m => GetMemberSetExpression(member, m)) .Where(m => m.NodeId == member.Id); await Database.ExecuteAsync(updateMemberQuery); - Sql updateContentVersionQuery = Sql() - .Update(m => m.Set(x => x.VersionDate, member.UpdateDate)) - .Where(m => m.NodeId == member.Id && m.Current == true); - await Database.ExecuteAsync(updateContentVersionQuery); - OnUowRefreshedEntity(new MemberRefreshNotification(member, new EventMessages())); _memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, member.Username); diff --git a/src/Umbraco.Infrastructure/Search/ExternalMemberIndexingNotificationHandler.cs b/src/Umbraco.Infrastructure/Search/ExternalMemberIndexingNotificationHandler.cs new file mode 100644 index 000000000000..553ff456c77b --- /dev/null +++ b/src/Umbraco.Infrastructure/Search/ExternalMemberIndexingNotificationHandler.cs @@ -0,0 +1,77 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.Search; + +/// +/// Handles indexing of external-only members in response to cache refresher notifications. +/// +/// +/// Like , this handler subscribes to a +/// rather than the domain notification directly. +/// This ensures Examine indexes are updated on all servers in a load-balanced environment. +/// +public sealed class ExternalMemberIndexingNotificationHandler : + INotificationHandler +{ + private readonly IExternalMemberService _externalMemberService; + private readonly IUmbracoIndexingHandler _umbracoIndexingHandler; + + /// + /// Initializes a new instance of the class. + /// + public ExternalMemberIndexingNotificationHandler( + IExternalMemberService externalMemberService, + IUmbracoIndexingHandler umbracoIndexingHandler) + { + _externalMemberService = externalMemberService; + _umbracoIndexingHandler = umbracoIndexingHandler; + } + + /// + /// Handles the by re-indexing + /// or removing external members from all registered member indexes. + /// + public void Handle(ExternalMemberCacheRefresherNotification notification) + { + if (!_umbracoIndexingHandler.Enabled) + { + return; + } + + if (Suspendable.ExamineEvents.CanIndex == false) + { + return; + } + + if (notification.MessageType != MessageType.RefreshByPayload) + { + return; + } + + var payloads = (ExternalMemberCacheRefresher.JsonPayload[])notification.MessageObject; + foreach (ExternalMemberCacheRefresher.JsonPayload payload in payloads) + { + if (payload.Removed) + { + _umbracoIndexingHandler.DeleteExternalMemberFromIndex(payload.Id); + } + else if (payload.IndexableFieldsChanged) + { + ExternalMemberIdentity? member = _externalMemberService.GetByKeyAsync(payload.Key) + .GetAwaiter().GetResult(); + if (member is not null) + { + _umbracoIndexingHandler.ReIndexForExternalMember(member); + } + } + } + } +} diff --git a/src/Umbraco.Infrastructure/Search/IUmbracoIndexingHandler.cs b/src/Umbraco.Infrastructure/Search/IUmbracoIndexingHandler.cs index 81bedc3f8a37..ef6f2577b832 100644 --- a/src/Umbraco.Infrastructure/Search/IUmbracoIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Search/IUmbracoIndexingHandler.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Infrastructure.Search; @@ -29,6 +30,28 @@ public interface IUmbracoIndexingHandler /// The instance to re-index. void ReIndexForMember(IMember member); + /// + /// Re-indexes the specified in the search index. + /// + /// The instance to re-index. + // TODO (V19): Remove the default implementation. + void ReIndexForExternalMember(ExternalMemberIdentity member) + { + } + + /// + /// Remove an external member from the member indexes. + /// + /// The external member ID to remove from member indexes. + /// + /// External members have their own ID space (separate from umbracoNode) so they must + /// be removed from member-only indexes rather than through . + /// + // TODO (V19): Remove the default implementation. + void DeleteExternalMemberFromIndex(int externalMemberId) + { + } + /// /// Re-indexes the specified media item in the search index. /// diff --git a/src/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandler.cs b/src/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandler.cs index ce98e0d02657..210cf3e03426 100644 --- a/src/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandler.cs @@ -86,7 +86,7 @@ public void Handle(MemberCacheRefresherNotification args) { _umbracoIndexingHandler.DeleteIndexForEntity(p.Id, false); } - else + else if (p.IndexableFieldsChanged) { IMember? m = _memberService.GetById(p.Id); if (m != null) diff --git a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs index 3fd8edff8219..9de2b6af6028 100644 --- a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs @@ -117,7 +117,7 @@ private void Map(IUser source, BackOfficeIdentityUser target) target.Kind = source.Kind; } - // Umbraco.Code.MapAll -Id -LockoutEnabled -PhoneNumber -PhoneNumberConfirmed -ConcurrencyStamp -NormalizedEmail -NormalizedUserName -Roles + // Umbraco.Code.MapAll -Id -LockoutEnabled -PhoneNumber -PhoneNumberConfirmed -ConcurrencyStamp -NormalizedEmail -NormalizedUserName -Roles -IsExternalOnly -ProfileData private void Map(IMember source, MemberIdentityUser target) { target.Email = source.Email; diff --git a/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs b/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs index dc9b5500dcd1..67e3d8a0450a 100644 --- a/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/MemberIdentityUser.cs @@ -1,6 +1,4 @@ using System.Globalization; -using Umbraco.Cms.Core.Models.Membership; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security; @@ -9,13 +7,13 @@ namespace Umbraco.Cms.Core.Security; /// public class MemberIdentityUser : UmbracoIdentityUser { - // Custom comparer for enumerables - private static readonly DelegateEqualityComparer> _groupsComparer = new( - (groups, enumerable) => - groups?.Select(x => x.Alias).UnsortedSequenceEqual(enumerable?.Select(x => x.Alias)) ?? false, - groups => groups.GetHashCode()); - + // IDE0032: explicit backing fields are required because the Comments and ProfileData setters pass them by ref + // to BeingDirty.SetPropertyValueAndDetectChanges for change detection. + // Converting to auto-properties would silently remove the dirty tracking. +#pragma warning disable IDE0032 // Use auto property private string? _comments; + private string? _profileData; +#pragma warning restore IDE0032 // Use auto property /// /// Initializes a new instance of the class. @@ -65,6 +63,38 @@ public string? Comments /// public string? MemberTypeAlias { get; set; } + /// + /// Gets or sets a value indicating whether this member exists only as an external identity + /// (backed by the lightweight umbracoExternalMember table, not the content system). + /// + /// No change tracking because this is a routing flag set at load time. + public bool IsExternalOnly { get; set; } + + /// + /// Gets or sets arbitrary profile data as a JSON string. + /// Only used for external-only members. For content-based members, profile data + /// lives in content properties and this value is null. + /// + /// + /// Dirty-tracked so that login-path routing (see MemberUserStore.UpdateExternalMemberAsync) + /// can detect when an OnExternalLogin callback refreshes profile data and route to the + /// full update path — ensuring the change is persisted and the member index is refreshed. + /// + public string? ProfileData + { + get => _profileData; + set => BeingDirty.SetPropertyValueAndDetectChanges(value, ref _profileData, nameof(ProfileData)); + } + + /// + /// Deserialises the JSON string into a typed object. + /// + /// The type to deserialise to. + /// Optional JSON serializer options. If null, the default options are used. + /// The deserialised object, or default if is null or empty. + public T? GetProfileData(System.Text.Json.JsonSerializerOptions? options = null) where T : class => + string.IsNullOrEmpty(ProfileData) ? default : System.Text.Json.JsonSerializer.Deserialize(ProfileData, options); + /// /// Creates a new instance without assigning an identity. /// diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index 06bd0aface76..50a6be3ac711 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -2,29 +2,37 @@ using System.Globalization; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security; /// -/// A custom user store that uses Umbraco member data +/// A custom user store that uses Umbraco member data. /// public class MemberUserStore : UmbracoUserStore, IMemberUserStore { - private const string GenericIdentityErrorCode = "IdentityErrorUserStore"; + /// + /// Represents the error code used to indicate that an identity operation was canceled by the user store. + /// public const string CancelledIdentityErrorCode = "CancelledIdentityErrorUserStore"; + + private const string GenericIdentityErrorCode = "IdentityErrorUserStore"; + private readonly IExternalLoginWithKeyService _externalLoginService; private readonly IUmbracoMapper _mapper; private readonly IMemberService _memberService; private readonly ICoreScopeProvider _scopeProvider; private readonly ITwoFactorLoginService _twoFactorLoginService; private readonly IPublishedMemberCache _memberCache; + private readonly IExternalMemberService _externalMemberService; /// /// Initializes a new instance of the class for the members identity store @@ -36,6 +44,7 @@ public class MemberUserStore : UmbracoUserStoreThe external login service /// The two factor login service /// The published member cache for resolving member content. + /// The external member service for external-only members. public MemberUserStore( IMemberService memberService, IUmbracoMapper mapper, @@ -43,7 +52,8 @@ public MemberUserStore( IdentityErrorDescriber describer, IExternalLoginWithKeyService externalLoginService, ITwoFactorLoginService twoFactorLoginService, - IPublishedMemberCache memberCache) + IPublishedMemberCache memberCache, + IExternalMemberService externalMemberService) : base(describer) { _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); @@ -52,10 +62,42 @@ public MemberUserStore( _externalLoginService = externalLoginService; _twoFactorLoginService = twoFactorLoginService; _memberCache = memberCache; + _externalMemberService = externalMemberService ?? throw new ArgumentNullException(nameof(externalMemberService)); + } + + /// + /// Initializes a new instance of the class for the members identity store + /// + /// The member service + /// The mapper for properties + /// The scope provider + /// The error describer + /// The external login service + /// The two factor login service + /// The published member cache for resolving member content. + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public MemberUserStore( + IMemberService memberService, + IUmbracoMapper mapper, + ICoreScopeProvider scopeProvider, + IdentityErrorDescriber describer, + IExternalLoginWithKeyService externalLoginService, + ITwoFactorLoginService twoFactorLoginService, + IPublishedMemberCache memberCache) + : this( + memberService, + mapper, + scopeProvider, + describer, + externalLoginService, + twoFactorLoginService, + memberCache, + StaticServiceProvider.Instance.GetRequiredService()) + { } /// - public override Task CreateAsync( + public override async Task CreateAsync( MemberIdentityUser user, CancellationToken cancellationToken = default) { @@ -63,14 +105,19 @@ public override Task CreateAsync( { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user is null) + ArgumentNullException.ThrowIfNull(user); + + // External-only members are stored in the lightweight umbracoExternalMember table, + // bypassing the content system entirely (no umbracoNode, cmsContent, or property data). + if (user.IsExternalOnly) { - throw new ArgumentNullException(nameof(user)); + return await CreateExternalMemberAsync(user); } - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - - // create member + // Create the in-memory member entity before opening the write scope. + // CreateMember only does a member type lookup (in its own read-only scope) and + // builds the entity in memory — keeping it outside the write scope avoids holding + // table locks that conflict with concurrent write transactions on SQLite. IMember memberEntity = _memberService.CreateMember( user.UserName!, user.Email!, @@ -86,11 +133,11 @@ public override Task CreateAsync( // will detect this change of behavior. if (memberEntity.HasIdentity) { - return Task.FromResult(IdentityResult.Failed(new IdentityError + return IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = "Cannot assign a new key to a member that already has identity." - })); + }); } memberEntity.Key = user.Key; @@ -98,18 +145,20 @@ public override Task CreateAsync( UpdateMemberProperties(memberEntity, user, out bool _); - // create the member + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + // save the member Attempt saveAttempt = _memberService.Save(memberEntity, PublishNotificationSaveOptions.Saving); if (saveAttempt.Success is false) { scope.Complete(); - return Task.FromResult(IdentityResult.Failed( + return IdentityResult.Failed( new IdentityError { Code = CancelledIdentityErrorCode, Description = string.Empty - })); + }); } // We need to add roles now that the member has an Id. It do not work implicit in UpdateMemberProperties @@ -151,12 +200,11 @@ public override Task CreateAsync( } scope.Complete(); - return Task.FromResult(IdentityResult.Success); + return IdentityResult.Success; } catch (Exception ex) { - return Task.FromResult( - IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message })); + return IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }); } } @@ -169,9 +217,13 @@ public override async Task UpdateAsync( { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) + ArgumentNullException.ThrowIfNull(user); + + // External-only members are stored in the lightweight umbracoExternalMember table, + // bypassing the content system entirely (no umbracoNode, cmsContent, or property data). + if (user.IsExternalOnly) { - throw new ArgumentNullException(nameof(user)); + return await UpdateExternalMemberAsync(user); } if (!int.TryParse(user.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var asInt)) @@ -250,7 +302,7 @@ private static bool UpdatingOnlyLoginProperties(IReadOnlyList properties } /// - public override Task DeleteAsync( + public override async Task DeleteAsync( MemberIdentityUser user, CancellationToken cancellationToken = default) { @@ -258,9 +310,13 @@ public override Task DeleteAsync( { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) + ArgumentNullException.ThrowIfNull(user); + + // External-only members are stored in the lightweight umbracoExternalMember table, + // bypassing the content system entirely (no umbracoNode, cmsContent, or property data). + if (user.IsExternalOnly) { - throw new ArgumentNullException(nameof(user)); + return await DeleteExternalMemberAsync(user); } IMember? found = _memberService.GetById(user.Key); @@ -271,12 +327,11 @@ public override Task DeleteAsync( _externalLoginService.DeleteUserLogins(user.Key); - return Task.FromResult(IdentityResult.Success); + return IdentityResult.Success; } catch (Exception ex) { - return Task.FromResult( - IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message })); + return IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }); } } @@ -288,6 +343,13 @@ public override Task DeleteAsync( IMember? user = _memberService.GetByUsername(userName); if (user == null) { + // Check external member store + ExternalMemberIdentity? externalMember = _externalMemberService.GetByUsernameAsync(userName).GetAwaiter().GetResult(); + if (externalMember is not null) + { + return Task.FromResult(AssignLoginsCallback(MapExternalMemberToIdentityUser(externalMember))); + } + return Task.FromResult(null); } @@ -310,6 +372,13 @@ public override Task DeleteAsync( return null; } + if (user.IsExternalOnly) + { + ExternalMemberIdentity? external = _externalMemberService.GetByKeyAsync(user.Key) + .GetAwaiter().GetResult(); + return external is not null ? new PublishedExternalMember(external) : null; + } + IMember? member = _memberService.GetById(user.Key); if (member is null) { @@ -327,10 +396,19 @@ public override Task DeleteAsync( cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); IMember? member = _memberService.GetByEmail(email); - MemberIdentityUser? result = member == null - ? null - : _mapper.Map(member); + if (member == null) + { + // Check external member store + ExternalMemberIdentity? externalMember = _externalMemberService.GetByEmailAsync(email).GetAwaiter().GetResult(); + if (externalMember is not null) + { + return Task.FromResult(AssignLoginsCallback(MapExternalMemberToIdentityUser(externalMember))); + } + + return Task.FromResult(null); + } + MemberIdentityUser? result = _mapper.Map(member); return Task.FromResult(AssignLoginsCallback(result)); } @@ -357,6 +435,18 @@ public override Task DeleteAsync( if (user == null) { + // Check external member store — userId is the Guid key as a string for external members. + ExternalMemberIdentity? externalMember = null; + if (Guid.TryParse(userId, out Guid externalKey)) + { + externalMember = _externalMemberService.GetByKeyAsync(externalKey).GetAwaiter().GetResult(); + } + + if (externalMember is not null) + { + return Task.FromResult(AssignLoginsCallback(MapExternalMemberToIdentityUser(externalMember)))!; + } + return Task.FromResult((MemberIdentityUser)null!)!; } @@ -383,6 +473,7 @@ private bool TryResolveEntityIdFromIdentityId(string? identityId, out int entity return false; } + /// protected override Task ResolveEntityIdFromIdentityId(string? identityId) { if (TryResolveEntityIdFromIdentityId(identityId, out var entityId)) @@ -599,7 +690,16 @@ private void EnsureRoles(MemberIdentityUser user) { // if there are no roles, they either haven't been loaded since we don't eagerly // load for members, or they just have no roles. - IEnumerable currentRoles = _memberService.GetAllRoles(user.UserName!); + IEnumerable currentRoles; + if (user.IsExternalOnly) + { + currentRoles = _externalMemberService.GetRolesAsync(user.Key).GetAwaiter().GetResult(); + } + else + { + currentRoles = _memberService.GetAllRoles(user.UserName!); + } + ICollection> roles = currentRoles .Select(role => new IdentityUserRole { RoleId = role, UserId = user.Id }).ToList(); @@ -750,6 +850,215 @@ public override Task SetTokenAsync(MemberIdentityUser user, string loginProvider return user; } + private async Task CreateExternalMemberAsync(MemberIdentityUser user) + { + var externalIdentity = new ExternalMemberIdentity + { + Key = user.Key != Guid.Empty ? user.Key : Guid.NewGuid(), + Email = user.Email!, + UserName = user.UserName!, + Name = user.Name, + IsApproved = user.IsApproved, + SecurityStamp = user.SecurityStamp, + ProfileData = user.ProfileData, + CreateDate = DateTime.UtcNow, + }; + + // Build the external login upfront so it can be saved in the same transaction + // as the member creation, reducing database write-lock contention. + IExternalLogin? externalLogin = user.Logins + .Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey, x.UserData)) + .FirstOrDefault(); + + Attempt result = + await _externalMemberService.CreateAsync(externalIdentity, externalLogin); + + if (result.Success is false) + { + return IdentityResult.Failed(new IdentityError + { + Code = result.Status == ExternalMemberOperationStatus.CancelledByNotification + ? CancelledIdentityErrorCode + : GenericIdentityErrorCode, + Description = result.Status.ToString(), + }); + } + + // Use the Guid key as the user ID for external members so that FindUserAsync + // can resolve them via Guid.TryParse → GetByKeyAsync. Content members use int IDs, + // but external members don't have content node IDs. + user.Id = result.Result.Key.ToString(); + user.Key = result.Result.Key; + + // Handle roles. + var roles = user.Roles.Select(x => x.RoleId).Where(x => x is not null).ToArray(); + if (roles.Length > 0) + { + await _externalMemberService.AssignRolesAsync(result.Result.Key, roles); + } + + // Save any additional logins beyond the first (which was batched with create above). + if (user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)) && user.Logins.Count > 1) + { + _externalLoginService.Save( + result.Result.Key, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + x.UserData))); + } + + if (user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens))) + { + _externalLoginService.Save( + result.Result.Key, + user.LoginTokens.Select(x => new ExternalLoginToken( + x.LoginProvider, + x.Name, + x.Value))); + } + + return IdentityResult.Success; + } + + private async Task UpdateExternalMemberAsync(MemberIdentityUser user) + { + var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)); + var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens)); + + // Detect login-only updates — on the OIDC callback we typically only set LastLoginDate + // (and SecurityStamp when concurrent logins are disabled). In that case we route to the + // lightweight UpdateLoginPropertiesAsync which issues a targeted SQL UPDATE and lets the + // downstream indexing handler skip re-indexing when nothing indexable changed. + if (IsUpdatingOnlyLoginProperties(user)) + { + var loginIdentity = new ExternalMemberIdentity + { + Key = user.Key, + LastLoginDate = user.LastLoginDate, + SecurityStamp = user.SecurityStamp, + }; + + ExternalMemberIdentity? existingForLogin = await _externalMemberService.GetByKeyAsync(user.Key); + if (existingForLogin is not null) + { + loginIdentity.Id = existingForLogin.Id; + } + + await _externalMemberService.UpdateLoginPropertiesAsync(loginIdentity); + } + else + { + // Full update — covers ProfileData changes from OnExternalLogin callbacks, role updates, + // email/username changes etc. + var externalIdentity = new ExternalMemberIdentity + { + Key = user.Key, + Email = user.Email!, + UserName = user.UserName!, + Name = user.Name, + IsApproved = user.IsApproved, + IsLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd.Value >= DateTimeOffset.UtcNow, + LastLoginDate = user.LastLoginDate, + LastLockoutDate = user.LastLockoutDate, + CreateDate = user.CreatedDate, + SecurityStamp = user.SecurityStamp, + ProfileData = user.ProfileData, + }; + + // Resolve the int Id and CreateDate from the stored record — MemberIdentityUser + // doesn't carry the int Id (it uses Guid key as Id) and CreateDate may not be set. + ExternalMemberIdentity? existing = await _externalMemberService.GetByKeyAsync(user.Key); + if (existing is not null) + { + externalIdentity.Id = existing.Id; + if (externalIdentity.CreateDate == default) + { + externalIdentity.CreateDate = existing.CreateDate; + } + } + + await _externalMemberService.UpdateAsync(externalIdentity); + } + + if (isLoginsPropertyDirty) + { + _externalLoginService.Save( + user.Key, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + x.UserData))); + } + + if (isTokensPropertyDirty) + { + _externalLoginService.Save( + user.Key, + user.LoginTokens.Select(x => new ExternalLoginToken( + x.LoginProvider, + x.Name, + x.Value))); + } + + return IdentityResult.Success; + } + + private static bool IsUpdatingOnlyLoginProperties(MemberIdentityUser user) + { + // Only consider a login-only update if at least LastLoginDate or SecurityStamp is dirty, + // and none of the other tracked fields that would require a full update are dirty. + bool hasLoginChange = user.IsPropertyDirty(nameof(MemberIdentityUser.LastLoginDate)) + || user.IsPropertyDirty(nameof(MemberIdentityUser.SecurityStamp)); + if (hasLoginChange is false) + { + return false; + } + + string[] disqualifyingProperties = + [ + nameof(MemberIdentityUser.Email), + nameof(MemberIdentityUser.UserName), + nameof(MemberIdentityUser.Name), + nameof(MemberIdentityUser.IsApproved), + nameof(MemberIdentityUser.LockoutEnd), + nameof(MemberIdentityUser.LastLockoutDate), + nameof(MemberIdentityUser.ProfileData), + ]; + + return disqualifyingProperties.All(p => user.IsPropertyDirty(p) is false); + } + + private async Task DeleteExternalMemberAsync(MemberIdentityUser user) + { + await _externalMemberService.DeleteAsync(user.Key); + _externalLoginService.DeleteUserLogins(user.Key); + return IdentityResult.Success; + } + + private MemberIdentityUser MapExternalMemberToIdentityUser(ExternalMemberIdentity external) + { + var user = new MemberIdentityUser(); + user.DisableChangeTracking(); + // Use Guid key as ID for external members (content members use int node IDs). + // This ensures FindUserAsync can resolve external members via Guid.TryParse. + user.Id = external.Key.ToString(); + user.Key = external.Key; + user.UserName = external.UserName; + user.Email = external.Email; + user.Name = external.Name; + user.IsApproved = external.IsApproved; + user.LockoutEnd = external.IsLockedOut ? (external.LastLockoutDate ?? DateTime.MaxValue).ToUniversalTime() : null; + user.LastLoginDate = external.LastLoginDate; + user.LastLockoutDate = external.LastLockoutDate; + user.CreatedDate = external.CreateDate; + user.SecurityStamp = external.SecurityStamp; + user.IsExternalOnly = true; + user.ProfileData = external.ProfileData; + user.EnableChangeTracking(); + return user; + } + private IReadOnlyList UpdateMemberProperties(IMember member, MemberIdentityUser identityUser, out bool updateRoles) { var updatedProperties = new List(); diff --git a/src/Umbraco.Infrastructure/Services/Implement/MemberFilterService.cs b/src/Umbraco.Infrastructure/Services/Implement/MemberFilterService.cs new file mode 100644 index 000000000000..6eed95771cca --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/MemberFilterService.cs @@ -0,0 +1,43 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Services.Implement; + +/// +/// Provides filtered, paginated member listings spanning both content-based and external-only members. +/// +internal sealed class MemberFilterService : IMemberFilterService +{ + private readonly ICoreScopeProvider _scopeProvider; + private readonly IMemberFilterRepository _repository; + + /// + /// Initializes a new instance of the class. + /// + /// The scope provider for unit of work operations. + /// The repository for combined member queries. + public MemberFilterService(ICoreScopeProvider scopeProvider, IMemberFilterRepository repository) + { + _scopeProvider = scopeProvider; + _repository = repository; + } + + /// + public async Task> FilterAsync( + MemberFilter filter, + string orderBy = "username", + Direction orderDirection = Direction.Ascending, + int skip = 0, + int take = 100) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + return await _repository.GetPagedByFilterAsync(filter, skip, take, Ordering.By(orderBy, orderDirection)); + } +} diff --git a/src/Umbraco.Infrastructure/Services/MemberEditingService.cs b/src/Umbraco.Infrastructure/Services/MemberEditingService.cs index e56c64ee1f46..a88a0942bffb 100644 --- a/src/Umbraco.Infrastructure/Services/MemberEditingService.cs +++ b/src/Umbraco.Infrastructure/Services/MemberEditingService.cs @@ -12,6 +12,10 @@ namespace Umbraco.Cms.Core.Services; +/// +/// Provides services for validating, creating, updating, and deleting members, including support for member data +/// validation, password management, role assignment, and two-factor authentication operations. +/// internal sealed class MemberEditingService : IMemberEditingService { private readonly IMemberService _memberService; @@ -23,6 +27,7 @@ internal sealed class MemberEditingService : IMemberEditingService private readonly ILogger _logger; private readonly IMemberGroupService _memberGroupService; private readonly SecuritySettings _securitySettings; + private readonly IExternalMemberService _externalMemberService; /// /// Initializes a new instance of the class. @@ -36,6 +41,7 @@ internal sealed class MemberEditingService : IMemberEditingService /// The for logging. /// The for managing member groups. /// The containing security configuration. + /// The for cross-store uniqueness validation. public MemberEditingService( IMemberService memberService, IMemberTypeService memberTypeService, @@ -45,7 +51,8 @@ public MemberEditingService( IPasswordChanger passwordChanger, ILogger logger, IMemberGroupService memberGroupService, - IOptions securitySettings) + IOptions securitySettings, + IExternalMemberService externalMemberService) { _memberService = memberService; _memberTypeService = memberTypeService; @@ -56,6 +63,7 @@ public MemberEditingService( _logger = logger; _memberGroupService = memberGroupService; _securitySettings = securitySettings.Value; + _externalMemberService = externalMemberService; } /// @@ -255,6 +263,7 @@ public async Task> UpdateAsync( /// /// Asynchronously deletes a member by its unique key. + /// Checks for external-only members first and routes to the external member service if found. /// /// The unique key (identifier) of the member to delete. /// The unique key (identifier) of the user performing the deletion. @@ -263,6 +272,26 @@ public async Task> UpdateAsync( /// public async Task> DeleteAsync(Guid key, Guid userKey) { + // Check if this is an external-only member and delete via the external service. + ExternalMemberIdentity? externalMember = await _externalMemberService.GetByKeyAsync(key); + if (externalMember is not null) + { + Attempt externalResult = await _externalMemberService.DeleteAsync(key); + return externalResult.Success + ? Attempt.SucceedWithStatus( + new MemberEditingStatus + { + MemberEditingOperationStatus = MemberEditingOperationStatus.Success, + }, + (IMember?)null) + : Attempt.FailWithStatus( + new MemberEditingStatus + { + MemberEditingOperationStatus = MemberEditingOperationStatus.MemberNotFound, + }, + (IMember?)null); + } + Attempt contentDeleteResult = await _memberContentEditingService.DeleteAsync(key, userKey); return contentDeleteResult.Success ? Attempt.SucceedWithStatus( @@ -281,7 +310,19 @@ public async Task> UpdateAsync( contentDeleteResult.Result); } - private async Task> ValidateMember(MemberEditingModelBase model, Guid? memberKey, string? password, Guid memberTypeKey) + /// + /// Checks whether the specified key belongs to an external-only member. + /// + /// The unique key to check. + /// true if the key belongs to an external-only member; otherwise, false. + public async Task IsExternalMemberAsync(Guid key) + => await _externalMemberService.GetByKeyAsync(key) is not null; + + /// + public async Task GetExternalMemberAsync(Guid key) + => await _externalMemberService.GetByKeyAsync(key); + + private async Task> ValidateMember(MemberEditingModelBase model, Guid? memberKey, string? password, Guid memberTypeKey) { var validationErrors = new List(); MemberEditingOperationStatus validationStatus = await ValidateMemberDataAsync(model, memberKey, password); @@ -388,6 +429,13 @@ private async Task ValidateMemberDataAsync(MemberE return MemberEditingOperationStatus.DuplicateUsername; } + // Cross-store uniqueness: also check external members for duplicate username. + ExternalMemberIdentity? externalByUsername = await _externalMemberService.GetByUsernameAsync(model.Username); + if (externalByUsername is not null && externalByUsername.Key != memberKey) + { + return MemberEditingOperationStatus.DuplicateUsername; + } + if (_securitySettings.MemberRequireUniqueEmail) { IMember? byEmail = _memberService.GetByEmail(model.Email); @@ -395,6 +443,13 @@ private async Task ValidateMemberDataAsync(MemberE { return MemberEditingOperationStatus.DuplicateEmail; } + + // Cross-store uniqueness: also check external members for duplicate email. + ExternalMemberIdentity? externalByEmail = await _externalMemberService.GetByEmailAsync(model.Email); + if (externalByEmail is not null && externalByEmail.Key != memberKey) + { + return MemberEditingOperationStatus.DuplicateEmail; + } } return MemberEditingOperationStatus.Success; diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs index 0b240bf42525..fd2df26c4f8a 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs @@ -47,7 +47,8 @@ public static IUmbracoBuilder AddMembersIdentity(this IUmbracoBuilder builder) factory.GetRequiredService(), factory.GetRequiredService(), factory.GetRequiredService(), - factory.GetRequiredService())) + factory.GetRequiredService(), + factory.GetRequiredService())) .AddRoleStore() .AddRoleManager() .AddMemberManager() diff --git a/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs index 5c79399cb04b..5ebf3039d4be 100644 --- a/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs @@ -64,6 +64,13 @@ public MemberExternalSignInAutoLinkOptions( /// public IEnumerable DefaultMemberGroups { get; } + /// + /// Gets or sets a value indicating whether auto-linked members should be created as external-only + /// (lightweight identity record, not a full content entity). When true, the external provider + /// is the source of truth for profile data and the member is stored in the umbracoExternalMember table. + /// + public bool ExternalOnly { get; set; } + /// /// The default Culture to use for auto-linking users /// diff --git a/src/Umbraco.Web.Common/Security/MemberManager.cs b/src/Umbraco.Web.Common/Security/MemberManager.cs index 0e3a0e652a44..11c355c1c500 100644 --- a/src/Umbraco.Web.Common/Security/MemberManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberManager.cs @@ -228,6 +228,47 @@ public virtual Task> IsProtectedAsync(IEnumera return _currentMember; } + /// + /// Generates a password reset token for the specified member. + /// External-only members cannot reset passwords as they authenticate via their external provider. + /// + /// The member to generate the reset token for. + /// The password reset token, or throws if the member is external-only. + /// Thrown when the member is an external-only member. + public override Task GeneratePasswordResetTokenAsync(MemberIdentityUser user) + { + if (user.IsExternalOnly) + { + throw new InvalidOperationException( + "Cannot generate a password reset token for an external-only member. " + + "This member authenticates via an external provider — use the provider's password recovery mechanism."); + } + + return base.GeneratePasswordResetTokenAsync(user); + } + + /// + /// Resets the password for the specified member using a reset token. + /// External-only members cannot have local passwords. + /// + /// The member whose password is being reset. + /// The password reset token. + /// The new password. + /// An indicating the result of the operation. + public override Task ResetPasswordAsync(MemberIdentityUser user, string token, string newPassword) + { + if (user.IsExternalOnly) + { + return Task.FromResult(IdentityResult.Failed(new IdentityError + { + Code = "ExternalMemberCannotResetPassword", + Description = "Cannot reset password for an external-only member. This member authenticates via an external provider.", + })); + } + + return base.ResetPasswordAsync(user, token, newPassword); + } + /// public virtual IPublishedContent? AsPublishedMember(MemberIdentityUser user) { diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index 4e7c0462a67d..014c48a75fce 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -162,6 +162,13 @@ public virtual async Task ExternalLoginSignInAsync(ExternalLoginIn return await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions); } + // For external-only members, sync identity fields from the external provider's claims + // on each login. The external provider is the source of truth for these fields. + if (user.IsExternalOnly) + { + SyncExternalMemberIdentityFields(user, loginInfo); + } + if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) { var shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); @@ -172,6 +179,9 @@ public virtual async Task ExternalLoginSignInAsync(ExternalLoginIn } } + // Changes from SyncExternalMemberIdentityFields and OnExternalLogin are persisted + // by ASP.NET Identity's own UpdateAsync call during sign-in (security stamp update). + SignInResult? error = await PreSignInCheck(user); if (error != null) { @@ -269,6 +279,13 @@ private async Task AutoLinkAndSignInExternalAccount( autoLinkUser = MemberIdentityUser.CreateNew(email!, email!, autoLinkOptions.DefaultMemberTypeAlias, autoLinkOptions.DefaultIsApproved, name); + // When ExternalOnly is enabled, the member is stored as a lightweight identity record + // in the umbracoExternalMember table, bypassing the content system entirely. + if (autoLinkOptions.ExternalOnly) + { + autoLinkUser.IsExternalOnly = true; + } + foreach (var userGroup in autoLinkOptions.DefaultMemberGroups) { autoLinkUser.AddRole(userGroup); @@ -338,6 +355,26 @@ protected Task HandleFailedLinkingUser(MemberIdentityUser return Task.FromResult(AutoLinkSignInResult.FailedLinkingUser(errors)); } + /// + /// Syncs identity fields on an external-only member from the external provider's claims. + /// The external provider is the source of truth for these fields. + /// + private static void SyncExternalMemberIdentityFields(MemberIdentityUser user, ExternalLoginInfo loginInfo) + { + var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); + if (email.IsNullOrWhiteSpace() is false) + { + user.Email = email; + user.UserName = email; + } + + var name = loginInfo.Principal?.Identity?.Name; + if (name.IsNullOrWhiteSpace() is false) + { + user.Name = name; + } + } + private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, MemberIdentityUser user) => Logger.LogWarning( "The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 76561af12582..06f8c49bcc44 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -436,6 +436,11 @@ export default { memberHasPassword: 'The member already has a password set', memberKindDefault: 'Member', memberKindApi: 'API Member', + memberKindExternalOnly: 'External', + profileData: 'Profile data', + externalMemberTitle: 'External member', + externalMemberDescription: + 'This member is managed by an external authentication provider. Identity data such as email and username is maintained by the provider, not Umbraco.', memberLockoutNotEnabled: 'Lockout is not enabled for this member', memberNotInGroup: "The member is not in group '%0%'", }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts index 9e8060fc6064..643a000a3d1d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteDataTypeByIdData, DeleteDataTypeByIdErrors, DeleteDataTypeByIdResponses, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdErrors, DeleteDataTypeFolderByIdResponses, DeleteDictionaryByIdData, DeleteDictionaryByIdErrors, DeleteDictionaryByIdResponses, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdErrors, DeleteDocumentBlueprintByIdResponses, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdErrors, DeleteDocumentBlueprintFolderByIdResponses, DeleteDocumentByIdData, DeleteDocumentByIdErrors, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessErrors, DeleteDocumentByIdPublicAccessResponses, DeleteDocumentByIdResponses, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdErrors, DeleteDocumentTypeByIdResponses, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdErrors, DeleteDocumentTypeFolderByIdResponses, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeErrors, DeleteLanguageByIsoCodeResponses, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameErrors, DeleteLogViewerSavedSearchByNameResponses, DeleteMediaByIdData, DeleteMediaByIdErrors, DeleteMediaByIdResponses, DeleteMediaTypeByIdData, DeleteMediaTypeByIdErrors, DeleteMediaTypeByIdResponses, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdErrors, DeleteMediaTypeFolderByIdResponses, DeleteMemberByIdData, DeleteMemberByIdErrors, DeleteMemberByIdResponses, DeleteMemberGroupByIdData, DeleteMemberGroupByIdErrors, DeleteMemberGroupByIdResponses, DeleteMemberTypeByIdData, DeleteMemberTypeByIdErrors, DeleteMemberTypeByIdResponses, DeleteMemberTypeFolderByIdData, DeleteMemberTypeFolderByIdErrors, DeleteMemberTypeFolderByIdResponses, DeletePackageCreatedByIdData, DeletePackageCreatedByIdErrors, DeletePackageCreatedByIdResponses, DeletePartialViewByPathData, DeletePartialViewByPathErrors, DeletePartialViewByPathResponses, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathErrors, DeletePartialViewFolderByPathResponses, DeletePreviewData, DeletePreviewResponses, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdErrors, DeleteRecycleBinDocumentByIdResponses, DeleteRecycleBinDocumentData, DeleteRecycleBinDocumentErrors, DeleteRecycleBinDocumentResponses, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdErrors, DeleteRecycleBinMediaByIdResponses, DeleteRecycleBinMediaData, DeleteRecycleBinMediaErrors, DeleteRecycleBinMediaResponses, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdErrors, DeleteRedirectManagementByIdResponses, DeleteScriptByPathData, DeleteScriptByPathErrors, DeleteScriptByPathResponses, DeleteScriptFolderByPathData, DeleteScriptFolderByPathErrors, DeleteScriptFolderByPathResponses, DeleteStylesheetByPathData, DeleteStylesheetByPathErrors, DeleteStylesheetByPathResponses, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathErrors, DeleteStylesheetFolderByPathResponses, DeleteTemplateByIdData, DeleteTemplateByIdErrors, DeleteTemplateByIdResponses, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdErrors, DeleteTemporaryFileByIdResponses, DeleteUserAvatarByIdData, DeleteUserAvatarByIdErrors, DeleteUserAvatarByIdResponses, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameErrors, DeleteUserById2FaByProviderNameResponses, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdErrors, DeleteUserByIdClientCredentialsByClientIdResponses, DeleteUserByIdData, DeleteUserByIdErrors, DeleteUserByIdResponses, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameErrors, DeleteUserCurrent2FaByProviderNameResponses, DeleteUserData, DeleteUserDataByIdData, DeleteUserDataByIdErrors, DeleteUserDataByIdResponses, DeleteUserErrors, DeleteUserGroupByIdData, DeleteUserGroupByIdErrors, DeleteUserGroupByIdResponses, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersErrors, DeleteUserGroupByIdUsersResponses, DeleteUserGroupData, DeleteUserGroupErrors, DeleteUserGroupResponses, DeleteUserResponses, DeleteWebhookByIdData, DeleteWebhookByIdErrors, DeleteWebhookByIdResponses, GetCollectionDocumentByIdData, GetCollectionDocumentByIdErrors, GetCollectionDocumentByIdResponses, GetCollectionMediaData, GetCollectionMediaErrors, GetCollectionMediaResponses, GetCultureData, GetCultureErrors, GetCultureResponses, GetDataTypeBatchData, GetDataTypeBatchErrors, GetDataTypeBatchResponses, GetDataTypeByIdData, GetDataTypeByIdErrors, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedErrors, GetDataTypeByIdIsUsedResponses, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByErrors, GetDataTypeByIdReferencedByResponses, GetDataTypeByIdResponses, GetDataTypeByIdSchemaData, GetDataTypeByIdSchemaErrors, GetDataTypeByIdSchemaResponses, GetDataTypeConfigurationData, GetDataTypeConfigurationErrors, GetDataTypeConfigurationResponses, GetDataTypeFolderByIdData, GetDataTypeFolderByIdErrors, GetDataTypeFolderByIdResponses, GetDataTypeSchemasBatchData, GetDataTypeSchemasBatchErrors, GetDataTypeSchemasBatchResponses, GetDictionaryByIdData, GetDictionaryByIdErrors, GetDictionaryByIdExportData, GetDictionaryByIdExportErrors, GetDictionaryByIdExportResponses, GetDictionaryByIdResponses, GetDictionaryData, GetDictionaryErrors, GetDictionaryResponses, GetDocumentAreReferencedData, GetDocumentAreReferencedErrors, GetDocumentAreReferencedResponses, GetDocumentBlueprintByIdAuditLogData, GetDocumentBlueprintByIdAuditLogErrors, GetDocumentBlueprintByIdAuditLogResponses, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdErrors, GetDocumentBlueprintByIdResponses, GetDocumentBlueprintByIdScaffoldData, GetDocumentBlueprintByIdScaffoldErrors, GetDocumentBlueprintByIdScaffoldResponses, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdErrors, GetDocumentBlueprintFolderByIdResponses, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogErrors, GetDocumentByIdAuditLogResponses, GetDocumentByIdAvailableSegmentOptionsData, GetDocumentByIdAvailableSegmentOptionsErrors, GetDocumentByIdAvailableSegmentOptionsResponses, GetDocumentByIdData, GetDocumentByIdDomainsData, GetDocumentByIdDomainsErrors, GetDocumentByIdDomainsResponses, GetDocumentByIdErrors, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsErrors, GetDocumentByIdNotificationsResponses, GetDocumentByIdPreviewUrlData, GetDocumentByIdPreviewUrlErrors, GetDocumentByIdPreviewUrlResponses, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessErrors, GetDocumentByIdPublicAccessResponses, GetDocumentByIdPublishedData, GetDocumentByIdPublishedErrors, GetDocumentByIdPublishedResponses, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdErrors, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponses, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByErrors, GetDocumentByIdReferencedByResponses, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsErrors, GetDocumentByIdReferencedDescendantsResponses, GetDocumentByIdResponses, GetDocumentConfigurationData, GetDocumentConfigurationErrors, GetDocumentConfigurationResponses, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootErrors, GetDocumentTypeAllowedAtRootResponses, GetDocumentTypeBatchData, GetDocumentTypeBatchErrors, GetDocumentTypeBatchResponses, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenErrors, GetDocumentTypeByIdAllowedChildrenResponses, GetDocumentTypeByIdAllowedParentsData, GetDocumentTypeByIdAllowedParentsErrors, GetDocumentTypeByIdAllowedParentsResponses, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintErrors, GetDocumentTypeByIdBlueprintResponses, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesErrors, GetDocumentTypeByIdCompositionReferencesResponses, GetDocumentTypeByIdData, GetDocumentTypeByIdErrors, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportErrors, GetDocumentTypeByIdExportResponses, GetDocumentTypeByIdResponses, GetDocumentTypeByIdSchemaData, GetDocumentTypeByIdSchemaErrors, GetDocumentTypeByIdSchemaResponses, GetDocumentTypeConfigurationData, GetDocumentTypeConfigurationErrors, GetDocumentTypeConfigurationResponses, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdErrors, GetDocumentTypeFolderByIdResponses, GetDocumentUrlsData, GetDocumentUrlsErrors, GetDocumentUrlsResponses, GetDocumentVersionByIdData, GetDocumentVersionByIdErrors, GetDocumentVersionByIdResponses, GetDocumentVersionData, GetDocumentVersionErrors, GetDocumentVersionResponses, GetDynamicRootStepsData, GetDynamicRootStepsErrors, GetDynamicRootStepsResponses, GetFilterDataTypeData, GetFilterDataTypeErrors, GetFilterDataTypeResponses, GetFilterMemberData, GetFilterMemberErrors, GetFilterMemberResponses, GetFilterUserData, GetFilterUserErrors, GetFilterUserGroupData, GetFilterUserGroupErrors, GetFilterUserGroupResponses, GetFilterUserResponses, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameErrors, GetHealthCheckGroupByNameResponses, GetHealthCheckGroupData, GetHealthCheckGroupErrors, GetHealthCheckGroupResponses, GetHelpData, GetHelpErrors, GetHelpResponses, GetImagingResizeUrlsData, GetImagingResizeUrlsErrors, GetImagingResizeUrlsResponses, GetImportAnalyzeData, GetImportAnalyzeErrors, GetImportAnalyzeResponses, GetIndexerByIndexNameData, GetIndexerByIndexNameErrors, GetIndexerByIndexNameResponses, GetIndexerData, GetIndexerErrors, GetIndexerResponses, GetInstallSettingsData, GetInstallSettingsErrors, GetInstallSettingsResponses, GetItemDataTypeAncestorsData, GetItemDataTypeAncestorsErrors, GetItemDataTypeAncestorsResponses, GetItemDataTypeData, GetItemDataTypeErrors, GetItemDataTypeResponses, GetItemDataTypeSearchData, GetItemDataTypeSearchErrors, GetItemDataTypeSearchResponses, GetItemDictionaryData, GetItemDictionaryErrors, GetItemDictionaryResponses, GetItemDocumentAncestorsData, GetItemDocumentAncestorsErrors, GetItemDocumentAncestorsResponses, GetItemDocumentBlueprintData, GetItemDocumentBlueprintErrors, GetItemDocumentBlueprintResponses, GetItemDocumentData, GetItemDocumentErrors, GetItemDocumentResponses, GetItemDocumentSearchData, GetItemDocumentSearchErrors, GetItemDocumentSearchResponses, GetItemDocumentTypeAncestorsData, GetItemDocumentTypeAncestorsErrors, GetItemDocumentTypeAncestorsResponses, GetItemDocumentTypeData, GetItemDocumentTypeErrors, GetItemDocumentTypeResponses, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchErrors, GetItemDocumentTypeSearchResponses, GetItemLanguageData, GetItemLanguageDefaultData, GetItemLanguageDefaultErrors, GetItemLanguageDefaultResponses, GetItemLanguageErrors, GetItemLanguageResponses, GetItemMediaAncestorsData, GetItemMediaAncestorsErrors, GetItemMediaAncestorsResponses, GetItemMediaData, GetItemMediaErrors, GetItemMediaResponses, GetItemMediaSearchData, GetItemMediaSearchErrors, GetItemMediaSearchResponses, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedErrors, GetItemMediaTypeAllowedResponses, GetItemMediaTypeAncestorsData, GetItemMediaTypeAncestorsErrors, GetItemMediaTypeAncestorsResponses, GetItemMediaTypeData, GetItemMediaTypeErrors, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersErrors, GetItemMediaTypeFoldersResponses, GetItemMediaTypeResponses, GetItemMediaTypeSearchData, GetItemMediaTypeSearchErrors, GetItemMediaTypeSearchResponses, GetItemMemberAncestorsData, GetItemMemberAncestorsErrors, GetItemMemberAncestorsResponses, GetItemMemberData, GetItemMemberErrors, GetItemMemberGroupData, GetItemMemberGroupErrors, GetItemMemberGroupResponses, GetItemMemberResponses, GetItemMemberSearchData, GetItemMemberSearchErrors, GetItemMemberSearchResponses, GetItemMemberTypeAncestorsData, GetItemMemberTypeAncestorsErrors, GetItemMemberTypeAncestorsResponses, GetItemMemberTypeData, GetItemMemberTypeErrors, GetItemMemberTypeResponses, GetItemMemberTypeSearchData, GetItemMemberTypeSearchErrors, GetItemMemberTypeSearchResponses, GetItemPartialViewData, GetItemPartialViewErrors, GetItemPartialViewResponses, GetItemRelationTypeData, GetItemRelationTypeErrors, GetItemRelationTypeResponses, GetItemScriptData, GetItemScriptErrors, GetItemScriptResponses, GetItemStaticFileData, GetItemStaticFileErrors, GetItemStaticFileResponses, GetItemStylesheetData, GetItemStylesheetErrors, GetItemStylesheetResponses, GetItemTemplateAncestorsData, GetItemTemplateAncestorsErrors, GetItemTemplateAncestorsResponses, GetItemTemplateData, GetItemTemplateErrors, GetItemTemplateResponses, GetItemTemplateSearchData, GetItemTemplateSearchErrors, GetItemTemplateSearchResponses, GetItemUserData, GetItemUserErrors, GetItemUserGroupData, GetItemUserGroupErrors, GetItemUserGroupResponses, GetItemUserResponses, GetItemWebhookData, GetItemWebhookErrors, GetItemWebhookResponses, GetLanguageByIsoCodeData, GetLanguageByIsoCodeErrors, GetLanguageByIsoCodeResponses, GetLanguageData, GetLanguageErrors, GetLanguageResponses, GetLogViewerLevelCountData, GetLogViewerLevelCountErrors, GetLogViewerLevelCountResponses, GetLogViewerLevelData, GetLogViewerLevelErrors, GetLogViewerLevelResponses, GetLogViewerLogData, GetLogViewerLogErrors, GetLogViewerLogResponses, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateErrors, GetLogViewerMessageTemplateResponses, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameErrors, GetLogViewerSavedSearchByNameResponses, GetLogViewerSavedSearchData, GetLogViewerSavedSearchErrors, GetLogViewerSavedSearchResponses, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeErrors, GetLogViewerValidateLogsSizeResponses, GetManifestManifestData, GetManifestManifestErrors, GetManifestManifestPrivateData, GetManifestManifestPrivateErrors, GetManifestManifestPrivateResponses, GetManifestManifestPublicData, GetManifestManifestPublicResponses, GetManifestManifestResponses, GetMediaAreReferencedData, GetMediaAreReferencedErrors, GetMediaAreReferencedResponses, GetMediaByIdAuditLogData, GetMediaByIdAuditLogErrors, GetMediaByIdAuditLogResponses, GetMediaByIdData, GetMediaByIdErrors, GetMediaByIdReferencedByData, GetMediaByIdReferencedByErrors, GetMediaByIdReferencedByResponses, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsErrors, GetMediaByIdReferencedDescendantsResponses, GetMediaByIdResponses, GetMediaConfigurationData, GetMediaConfigurationErrors, GetMediaConfigurationResponses, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootErrors, GetMediaTypeAllowedAtRootResponses, GetMediaTypeBatchData, GetMediaTypeBatchErrors, GetMediaTypeBatchResponses, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenErrors, GetMediaTypeByIdAllowedChildrenResponses, GetMediaTypeByIdAllowedParentsData, GetMediaTypeByIdAllowedParentsErrors, GetMediaTypeByIdAllowedParentsResponses, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesErrors, GetMediaTypeByIdCompositionReferencesResponses, GetMediaTypeByIdData, GetMediaTypeByIdErrors, GetMediaTypeByIdExportData, GetMediaTypeByIdExportErrors, GetMediaTypeByIdExportResponses, GetMediaTypeByIdResponses, GetMediaTypeByIdSchemaData, GetMediaTypeByIdSchemaErrors, GetMediaTypeByIdSchemaResponses, GetMediaTypeConfigurationData, GetMediaTypeConfigurationErrors, GetMediaTypeConfigurationResponses, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdErrors, GetMediaTypeFolderByIdResponses, GetMediaUrlsData, GetMediaUrlsErrors, GetMediaUrlsResponses, GetMemberAreReferencedData, GetMemberAreReferencedErrors, GetMemberAreReferencedResponses, GetMemberByIdData, GetMemberByIdErrors, GetMemberByIdReferencedByData, GetMemberByIdReferencedByErrors, GetMemberByIdReferencedByResponses, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsErrors, GetMemberByIdReferencedDescendantsResponses, GetMemberByIdResponses, GetMemberConfigurationData, GetMemberConfigurationErrors, GetMemberConfigurationResponses, GetMemberGroupByIdData, GetMemberGroupByIdErrors, GetMemberGroupByIdResponses, GetMemberGroupData, GetMemberGroupErrors, GetMemberGroupResponses, GetMemberTypeAllowedAtRootData, GetMemberTypeAllowedAtRootErrors, GetMemberTypeAllowedAtRootResponses, GetMemberTypeBatchData, GetMemberTypeBatchErrors, GetMemberTypeBatchResponses, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesErrors, GetMemberTypeByIdCompositionReferencesResponses, GetMemberTypeByIdData, GetMemberTypeByIdErrors, GetMemberTypeByIdExportData, GetMemberTypeByIdExportErrors, GetMemberTypeByIdExportResponses, GetMemberTypeByIdResponses, GetMemberTypeByIdSchemaData, GetMemberTypeByIdSchemaErrors, GetMemberTypeByIdSchemaResponses, GetMemberTypeConfigurationData, GetMemberTypeConfigurationErrors, GetMemberTypeConfigurationResponses, GetMemberTypeFolderByIdData, GetMemberTypeFolderByIdErrors, GetMemberTypeFolderByIdResponses, GetModelsBuilderDashboardData, GetModelsBuilderDashboardErrors, GetModelsBuilderDashboardResponses, GetModelsBuilderStatusData, GetModelsBuilderStatusErrors, GetModelsBuilderStatusResponses, GetNewsDashboardData, GetNewsDashboardErrors, GetNewsDashboardResponses, GetObjectTypesData, GetObjectTypesErrors, GetObjectTypesResponses, GetOembedQueryData, GetOembedQueryErrors, GetOembedQueryResponses, GetPackageConfigurationData, GetPackageConfigurationErrors, GetPackageConfigurationResponses, GetPackageCreatedByIdData, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadErrors, GetPackageCreatedByIdDownloadResponses, GetPackageCreatedByIdErrors, GetPackageCreatedByIdResponses, GetPackageCreatedData, GetPackageCreatedErrors, GetPackageCreatedResponses, GetPackageMigrationStatusData, GetPackageMigrationStatusErrors, GetPackageMigrationStatusResponses, GetPartialViewByPathData, GetPartialViewByPathErrors, GetPartialViewByPathResponses, GetPartialViewFolderByPathData, GetPartialViewFolderByPathErrors, GetPartialViewFolderByPathResponses, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdErrors, GetPartialViewSnippetByIdResponses, GetPartialViewSnippetData, GetPartialViewSnippetErrors, GetPartialViewSnippetResponses, GetProfilingStatusData, GetProfilingStatusErrors, GetProfilingStatusResponses, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedErrors, GetPropertyTypeIsUsedResponses, GetPublishedCacheRebuildStatusData, GetPublishedCacheRebuildStatusErrors, GetPublishedCacheRebuildStatusResponses, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentErrors, GetRecycleBinDocumentByIdOriginalParentResponses, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenErrors, GetRecycleBinDocumentChildrenResponses, GetRecycleBinDocumentReferencedByData, GetRecycleBinDocumentReferencedByErrors, GetRecycleBinDocumentReferencedByResponses, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootErrors, GetRecycleBinDocumentRootResponses, GetRecycleBinDocumentSiblingsData, GetRecycleBinDocumentSiblingsErrors, GetRecycleBinDocumentSiblingsResponses, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentErrors, GetRecycleBinMediaByIdOriginalParentResponses, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenErrors, GetRecycleBinMediaChildrenResponses, GetRecycleBinMediaReferencedByData, GetRecycleBinMediaReferencedByErrors, GetRecycleBinMediaReferencedByResponses, GetRecycleBinMediaRootData, GetRecycleBinMediaRootErrors, GetRecycleBinMediaRootResponses, GetRecycleBinMediaSiblingsData, GetRecycleBinMediaSiblingsErrors, GetRecycleBinMediaSiblingsResponses, GetRedirectManagementByIdData, GetRedirectManagementByIdErrors, GetRedirectManagementByIdResponses, GetRedirectManagementData, GetRedirectManagementErrors, GetRedirectManagementResponses, GetRedirectManagementStatusData, GetRedirectManagementStatusErrors, GetRedirectManagementStatusResponses, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdErrors, GetRelationByRelationTypeIdResponses, GetRelationTypeByIdData, GetRelationTypeByIdErrors, GetRelationTypeByIdResponses, GetRelationTypeData, GetRelationTypeErrors, GetRelationTypeResponses, GetScriptByPathData, GetScriptByPathErrors, GetScriptByPathResponses, GetScriptFolderByPathData, GetScriptFolderByPathErrors, GetScriptFolderByPathResponses, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryErrors, GetSearcherBySearcherNameQueryResponses, GetSearcherData, GetSearcherErrors, GetSearcherResponses, GetSecurityConfigurationData, GetSecurityConfigurationErrors, GetSecurityConfigurationResponses, GetSegmentData, GetSegmentErrors, GetSegmentResponses, GetServerConfigurationData, GetServerConfigurationResponses, GetServerInformationData, GetServerInformationErrors, GetServerInformationResponses, GetServerStatusData, GetServerStatusErrors, GetServerStatusResponses, GetServerTroubleshootingData, GetServerTroubleshootingErrors, GetServerTroubleshootingResponses, GetServerUpgradeCheckData, GetServerUpgradeCheckErrors, GetServerUpgradeCheckResponses, GetStylesheetByPathData, GetStylesheetByPathErrors, GetStylesheetByPathResponses, GetStylesheetFolderByPathData, GetStylesheetFolderByPathErrors, GetStylesheetFolderByPathResponses, GetTagData, GetTagErrors, GetTagResponses, GetTelemetryData, GetTelemetryErrors, GetTelemetryLevelData, GetTelemetryLevelErrors, GetTelemetryLevelResponses, GetTelemetryResponses, GetTemplateByIdData, GetTemplateByIdErrors, GetTemplateByIdResponses, GetTemplateConfigurationData, GetTemplateConfigurationErrors, GetTemplateConfigurationResponses, GetTemplateQuerySettingsData, GetTemplateQuerySettingsErrors, GetTemplateQuerySettingsResponses, GetTemporaryFileByIdData, GetTemporaryFileByIdErrors, GetTemporaryFileByIdResponses, GetTemporaryFileConfigurationData, GetTemporaryFileConfigurationErrors, GetTemporaryFileConfigurationResponses, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsErrors, GetTreeDataTypeAncestorsResponses, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenErrors, GetTreeDataTypeChildrenResponses, GetTreeDataTypeRootData, GetTreeDataTypeRootErrors, GetTreeDataTypeRootResponses, GetTreeDataTypeSearchData, GetTreeDataTypeSearchErrors, GetTreeDataTypeSearchResponses, GetTreeDataTypeSiblingsData, GetTreeDataTypeSiblingsErrors, GetTreeDataTypeSiblingsResponses, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsErrors, GetTreeDictionaryAncestorsResponses, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenErrors, GetTreeDictionaryChildrenResponses, GetTreeDictionaryRootData, GetTreeDictionaryRootErrors, GetTreeDictionaryRootResponses, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsErrors, GetTreeDocumentAncestorsResponses, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsErrors, GetTreeDocumentBlueprintAncestorsResponses, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenErrors, GetTreeDocumentBlueprintChildrenResponses, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootErrors, GetTreeDocumentBlueprintRootResponses, GetTreeDocumentBlueprintSiblingsData, GetTreeDocumentBlueprintSiblingsErrors, GetTreeDocumentBlueprintSiblingsResponses, GetTreeDocumentChildrenData, GetTreeDocumentChildrenErrors, GetTreeDocumentChildrenResponses, GetTreeDocumentRootData, GetTreeDocumentRootErrors, GetTreeDocumentRootResponses, GetTreeDocumentSiblingsData, GetTreeDocumentSiblingsErrors, GetTreeDocumentSiblingsResponses, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsErrors, GetTreeDocumentTypeAncestorsResponses, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenErrors, GetTreeDocumentTypeChildrenResponses, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootErrors, GetTreeDocumentTypeRootResponses, GetTreeDocumentTypeSearchData, GetTreeDocumentTypeSearchErrors, GetTreeDocumentTypeSearchResponses, GetTreeDocumentTypeSiblingsData, GetTreeDocumentTypeSiblingsErrors, GetTreeDocumentTypeSiblingsResponses, GetTreeMediaAncestorsData, GetTreeMediaAncestorsErrors, GetTreeMediaAncestorsResponses, GetTreeMediaChildrenData, GetTreeMediaChildrenErrors, GetTreeMediaChildrenResponses, GetTreeMediaRootData, GetTreeMediaRootErrors, GetTreeMediaRootResponses, GetTreeMediaSiblingsData, GetTreeMediaSiblingsErrors, GetTreeMediaSiblingsResponses, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsErrors, GetTreeMediaTypeAncestorsResponses, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenErrors, GetTreeMediaTypeChildrenResponses, GetTreeMediaTypeRootData, GetTreeMediaTypeRootErrors, GetTreeMediaTypeRootResponses, GetTreeMediaTypeSiblingsData, GetTreeMediaTypeSiblingsErrors, GetTreeMediaTypeSiblingsResponses, GetTreeMemberGroupRootData, GetTreeMemberGroupRootErrors, GetTreeMemberGroupRootResponses, GetTreeMemberTypeAncestorsData, GetTreeMemberTypeAncestorsErrors, GetTreeMemberTypeAncestorsResponses, GetTreeMemberTypeChildrenData, GetTreeMemberTypeChildrenErrors, GetTreeMemberTypeChildrenResponses, GetTreeMemberTypeRootData, GetTreeMemberTypeRootErrors, GetTreeMemberTypeRootResponses, GetTreeMemberTypeSiblingsData, GetTreeMemberTypeSiblingsErrors, GetTreeMemberTypeSiblingsResponses, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsErrors, GetTreePartialViewAncestorsResponses, GetTreePartialViewChildrenData, GetTreePartialViewChildrenErrors, GetTreePartialViewChildrenResponses, GetTreePartialViewRootData, GetTreePartialViewRootErrors, GetTreePartialViewRootResponses, GetTreePartialViewSiblingsData, GetTreePartialViewSiblingsErrors, GetTreePartialViewSiblingsResponses, GetTreeScriptAncestorsData, GetTreeScriptAncestorsErrors, GetTreeScriptAncestorsResponses, GetTreeScriptChildrenData, GetTreeScriptChildrenErrors, GetTreeScriptChildrenResponses, GetTreeScriptRootData, GetTreeScriptRootErrors, GetTreeScriptRootResponses, GetTreeScriptSiblingsData, GetTreeScriptSiblingsErrors, GetTreeScriptSiblingsResponses, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsErrors, GetTreeStaticFileAncestorsResponses, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenErrors, GetTreeStaticFileChildrenResponses, GetTreeStaticFileRootData, GetTreeStaticFileRootErrors, GetTreeStaticFileRootResponses, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsErrors, GetTreeStylesheetAncestorsResponses, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenErrors, GetTreeStylesheetChildrenResponses, GetTreeStylesheetRootData, GetTreeStylesheetRootErrors, GetTreeStylesheetRootResponses, GetTreeStylesheetSiblingsData, GetTreeStylesheetSiblingsErrors, GetTreeStylesheetSiblingsResponses, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsErrors, GetTreeTemplateAncestorsResponses, GetTreeTemplateChildrenData, GetTreeTemplateChildrenErrors, GetTreeTemplateChildrenResponses, GetTreeTemplateRootData, GetTreeTemplateRootErrors, GetTreeTemplateRootResponses, GetTreeTemplateSiblingsData, GetTreeTemplateSiblingsErrors, GetTreeTemplateSiblingsResponses, GetUpgradeSettingsData, GetUpgradeSettingsErrors, GetUpgradeSettingsResponses, GetUserById2FaData, GetUserById2FaErrors, GetUserById2FaResponses, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesErrors, GetUserByIdCalculateStartNodesResponses, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsErrors, GetUserByIdClientCredentialsResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, GetUserConfigurationData, GetUserConfigurationErrors, GetUserConfigurationResponses, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameErrors, GetUserCurrent2FaByProviderNameResponses, GetUserCurrent2FaData, GetUserCurrent2FaErrors, GetUserCurrent2FaResponses, GetUserCurrentConfigurationData, GetUserCurrentConfigurationErrors, GetUserCurrentConfigurationResponses, GetUserCurrentData, GetUserCurrentErrors, GetUserCurrentLoginProvidersData, GetUserCurrentLoginProvidersErrors, GetUserCurrentLoginProvidersResponses, GetUserCurrentPermissionsData, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentErrors, GetUserCurrentPermissionsDocumentResponses, GetUserCurrentPermissionsErrors, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaErrors, GetUserCurrentPermissionsMediaResponses, GetUserCurrentPermissionsResponses, GetUserCurrentResponses, GetUserData, GetUserDataByIdData, GetUserDataByIdErrors, GetUserDataByIdResponses, GetUserDataData, GetUserDataErrors, GetUserDataResponses, GetUserErrors, GetUserGroupByIdData, GetUserGroupByIdErrors, GetUserGroupByIdResponses, GetUserGroupData, GetUserGroupErrors, GetUserGroupResponses, GetUserResponses, GetWebhookByIdData, GetWebhookByIdErrors, GetWebhookByIdLogsData, GetWebhookByIdLogsErrors, GetWebhookByIdLogsResponses, GetWebhookByIdResponses, GetWebhookData, GetWebhookErrors, GetWebhookEventsData, GetWebhookEventsErrors, GetWebhookEventsResponses, GetWebhookLogsData, GetWebhookLogsErrors, GetWebhookLogsResponses, GetWebhookResponses, PostDataTypeByIdCopyData, PostDataTypeByIdCopyErrors, PostDataTypeByIdCopyResponses, PostDataTypeData, PostDataTypeErrors, PostDataTypeFolderData, PostDataTypeFolderErrors, PostDataTypeFolderResponses, PostDataTypeResponses, PostDictionaryData, PostDictionaryErrors, PostDictionaryImportData, PostDictionaryImportErrors, PostDictionaryImportResponses, PostDictionaryResponses, PostDocumentBlueprintData, PostDocumentBlueprintErrors, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderErrors, PostDocumentBlueprintFolderResponses, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentErrors, PostDocumentBlueprintFromDocumentResponses, PostDocumentBlueprintResponses, PostDocumentByIdCopyData, PostDocumentByIdCopyErrors, PostDocumentByIdCopyResponses, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessErrors, PostDocumentByIdPublicAccessResponses, PostDocumentData, PostDocumentErrors, PostDocumentResponses, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsErrors, PostDocumentTypeAvailableCompositionsResponses, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyErrors, PostDocumentTypeByIdCopyResponses, PostDocumentTypeByIdTemplateData, PostDocumentTypeByIdTemplateErrors, PostDocumentTypeByIdTemplateResponses, PostDocumentTypeData, PostDocumentTypeErrors, PostDocumentTypeFolderData, PostDocumentTypeFolderErrors, PostDocumentTypeFolderResponses, PostDocumentTypeImportData, PostDocumentTypeImportErrors, PostDocumentTypeImportResponses, PostDocumentTypeResponses, PostDocumentValidateData, PostDocumentValidateErrors, PostDocumentValidateResponses, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackErrors, PostDocumentVersionByIdRollbackResponses, PostDynamicRootQueryData, PostDynamicRootQueryErrors, PostDynamicRootQueryResponses, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionErrors, PostHealthCheckExecuteActionResponses, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckErrors, PostHealthCheckGroupByNameCheckResponses, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildErrors, PostIndexerByIndexNameRebuildResponses, PostInstallSetupData, PostInstallSetupErrors, PostInstallSetupResponses, PostInstallValidateDatabaseData, PostInstallValidateDatabaseErrors, PostInstallValidateDatabaseResponses, PostLanguageData, PostLanguageErrors, PostLanguageResponses, PostLogViewerSavedSearchData, PostLogViewerSavedSearchErrors, PostLogViewerSavedSearchResponses, PostMediaData, PostMediaErrors, PostMediaResponses, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsErrors, PostMediaTypeAvailableCompositionsResponses, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyErrors, PostMediaTypeByIdCopyResponses, PostMediaTypeData, PostMediaTypeErrors, PostMediaTypeFolderData, PostMediaTypeFolderErrors, PostMediaTypeFolderResponses, PostMediaTypeImportData, PostMediaTypeImportErrors, PostMediaTypeImportResponses, PostMediaTypeResponses, PostMediaValidateData, PostMediaValidateErrors, PostMediaValidateResponses, PostMemberData, PostMemberErrors, PostMemberGroupData, PostMemberGroupErrors, PostMemberGroupResponses, PostMemberResponses, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsErrors, PostMemberTypeAvailableCompositionsResponses, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyErrors, PostMemberTypeByIdCopyResponses, PostMemberTypeData, PostMemberTypeErrors, PostMemberTypeFolderData, PostMemberTypeFolderErrors, PostMemberTypeFolderResponses, PostMemberTypeImportData, PostMemberTypeImportErrors, PostMemberTypeImportResponses, PostMemberTypeResponses, PostMemberValidateData, PostMemberValidateErrors, PostMemberValidateResponses, PostModelsBuilderBuildData, PostModelsBuilderBuildErrors, PostModelsBuilderBuildResponses, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationErrors, PostPackageByNameRunMigrationResponses, PostPackageCreatedData, PostPackageCreatedErrors, PostPackageCreatedResponses, PostPartialViewData, PostPartialViewErrors, PostPartialViewFolderData, PostPartialViewFolderErrors, PostPartialViewFolderResponses, PostPartialViewResponses, PostPreviewData, PostPreviewErrors, PostPreviewResponses, PostPublishedCacheRebuildData, PostPublishedCacheRebuildErrors, PostPublishedCacheRebuildResponses, PostPublishedCacheReloadData, PostPublishedCacheReloadErrors, PostPublishedCacheReloadResponses, PostRedirectManagementStatusData, PostRedirectManagementStatusErrors, PostRedirectManagementStatusResponses, PostScriptData, PostScriptErrors, PostScriptFolderData, PostScriptFolderErrors, PostScriptFolderResponses, PostScriptResponses, PostSecurityForgotPasswordData, PostSecurityForgotPasswordErrors, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetErrors, PostSecurityForgotPasswordResetResponses, PostSecurityForgotPasswordResponses, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyErrors, PostSecurityForgotPasswordVerifyResponses, PostStylesheetData, PostStylesheetErrors, PostStylesheetFolderData, PostStylesheetFolderErrors, PostStylesheetFolderResponses, PostStylesheetResponses, PostTelemetryLevelData, PostTelemetryLevelErrors, PostTelemetryLevelResponses, PostTemplateData, PostTemplateErrors, PostTemplateQueryExecuteData, PostTemplateQueryExecuteErrors, PostTemplateQueryExecuteResponses, PostTemplateResponses, PostTemporaryFileData, PostTemporaryFileErrors, PostTemporaryFileResponses, PostUpgradeAuthorizeData, PostUpgradeAuthorizeErrors, PostUpgradeAuthorizeResponses, PostUserAvatarByIdData, PostUserAvatarByIdErrors, PostUserAvatarByIdResponses, PostUserByIdChangePasswordData, PostUserByIdChangePasswordErrors, PostUserByIdChangePasswordResponses, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsErrors, PostUserByIdClientCredentialsResponses, PostUserByIdResetPasswordData, PostUserByIdResetPasswordErrors, PostUserByIdResetPasswordResponses, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameErrors, PostUserCurrent2FaByProviderNameResponses, PostUserCurrentAvatarData, PostUserCurrentAvatarErrors, PostUserCurrentAvatarResponses, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordErrors, PostUserCurrentChangePasswordResponses, PostUserData, PostUserDataData, PostUserDataErrors, PostUserDataResponses, PostUserDisableData, PostUserDisableErrors, PostUserDisableResponses, PostUserEnableData, PostUserEnableErrors, PostUserEnableResponses, PostUserErrors, PostUserGroupByIdUsersData, PostUserGroupByIdUsersErrors, PostUserGroupByIdUsersResponses, PostUserGroupData, PostUserGroupErrors, PostUserGroupResponses, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordErrors, PostUserInviteCreatePasswordResponses, PostUserInviteData, PostUserInviteErrors, PostUserInviteResendData, PostUserInviteResendErrors, PostUserInviteResendResponses, PostUserInviteResponses, PostUserInviteVerifyData, PostUserInviteVerifyErrors, PostUserInviteVerifyResponses, PostUserResponses, PostUserSetUserGroupsData, PostUserSetUserGroupsErrors, PostUserSetUserGroupsResponses, PostUserUnlockData, PostUserUnlockErrors, PostUserUnlockResponses, PostWebhookData, PostWebhookErrors, PostWebhookResponses, PutDataTypeByIdData, PutDataTypeByIdErrors, PutDataTypeByIdMoveData, PutDataTypeByIdMoveErrors, PutDataTypeByIdMoveResponses, PutDataTypeByIdResponses, PutDataTypeFolderByIdData, PutDataTypeFolderByIdErrors, PutDataTypeFolderByIdResponses, PutDictionaryByIdData, PutDictionaryByIdErrors, PutDictionaryByIdMoveData, PutDictionaryByIdMoveErrors, PutDictionaryByIdMoveResponses, PutDictionaryByIdResponses, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdErrors, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveErrors, PutDocumentBlueprintByIdMoveResponses, PutDocumentBlueprintByIdResponses, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdErrors, PutDocumentBlueprintFolderByIdResponses, PutDocumentByIdData, PutDocumentByIdDomainsData, PutDocumentByIdDomainsErrors, PutDocumentByIdDomainsResponses, PutDocumentByIdErrors, PutDocumentByIdMoveData, PutDocumentByIdMoveErrors, PutDocumentByIdMoveResponses, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinErrors, PutDocumentByIdMoveToRecycleBinResponses, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsErrors, PutDocumentByIdNotificationsResponses, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessErrors, PutDocumentByIdPublicAccessResponses, PutDocumentByIdPublishData, PutDocumentByIdPublishErrors, PutDocumentByIdPublishResponses, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsErrors, PutDocumentByIdPublishWithDescendantsResponses, PutDocumentByIdResponses, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishErrors, PutDocumentByIdUnpublishResponses, PutDocumentSortData, PutDocumentSortErrors, PutDocumentSortResponses, PutDocumentTypeByIdData, PutDocumentTypeByIdErrors, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportErrors, PutDocumentTypeByIdImportResponses, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveErrors, PutDocumentTypeByIdMoveResponses, PutDocumentTypeByIdResponses, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdErrors, PutDocumentTypeFolderByIdResponses, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupErrors, PutDocumentVersionByIdPreventCleanupResponses, PutLanguageByIsoCodeData, PutLanguageByIsoCodeErrors, PutLanguageByIsoCodeResponses, PutMediaByIdData, PutMediaByIdErrors, PutMediaByIdMoveData, PutMediaByIdMoveErrors, PutMediaByIdMoveResponses, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinErrors, PutMediaByIdMoveToRecycleBinResponses, PutMediaByIdResponses, PutMediaByIdValidateData, PutMediaByIdValidateErrors, PutMediaByIdValidateResponses, PutMediaSortData, PutMediaSortErrors, PutMediaSortResponses, PutMediaTypeByIdData, PutMediaTypeByIdErrors, PutMediaTypeByIdImportData, PutMediaTypeByIdImportErrors, PutMediaTypeByIdImportResponses, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveErrors, PutMediaTypeByIdMoveResponses, PutMediaTypeByIdResponses, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdErrors, PutMediaTypeFolderByIdResponses, PutMemberByIdData, PutMemberByIdErrors, PutMemberByIdResponses, PutMemberByIdValidateData, PutMemberByIdValidateErrors, PutMemberByIdValidateResponses, PutMemberGroupByIdData, PutMemberGroupByIdErrors, PutMemberGroupByIdResponses, PutMemberTypeByIdData, PutMemberTypeByIdErrors, PutMemberTypeByIdImportData, PutMemberTypeByIdImportErrors, PutMemberTypeByIdImportResponses, PutMemberTypeByIdMoveData, PutMemberTypeByIdMoveErrors, PutMemberTypeByIdMoveResponses, PutMemberTypeByIdResponses, PutMemberTypeFolderByIdData, PutMemberTypeFolderByIdErrors, PutMemberTypeFolderByIdResponses, PutPackageCreatedByIdData, PutPackageCreatedByIdErrors, PutPackageCreatedByIdResponses, PutPartialViewByPathData, PutPartialViewByPathErrors, PutPartialViewByPathRenameData, PutPartialViewByPathRenameErrors, PutPartialViewByPathRenameResponses, PutPartialViewByPathResponses, PutProfilingStatusData, PutProfilingStatusErrors, PutProfilingStatusResponses, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreErrors, PutRecycleBinDocumentByIdRestoreResponses, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreErrors, PutRecycleBinMediaByIdRestoreResponses, PutScriptByPathData, PutScriptByPathErrors, PutScriptByPathRenameData, PutScriptByPathRenameErrors, PutScriptByPathRenameResponses, PutScriptByPathResponses, PutStylesheetByPathData, PutStylesheetByPathErrors, PutStylesheetByPathRenameData, PutStylesheetByPathRenameErrors, PutStylesheetByPathRenameResponses, PutStylesheetByPathResponses, PutTemplateByIdData, PutTemplateByIdErrors, PutTemplateByIdResponses, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Errors, PutUmbracoManagementApiV11DocumentByIdValidate11Responses, PutUserByIdData, PutUserByIdErrors, PutUserByIdResponses, PutUserDataData, PutUserDataErrors, PutUserDataResponses, PutUserGroupByIdData, PutUserGroupByIdErrors, PutUserGroupByIdResponses, PutWebhookByIdData, PutWebhookByIdErrors, PutWebhookByIdResponses } from './types.gen'; +import type { DeleteDataTypeByIdData, DeleteDataTypeByIdErrors, DeleteDataTypeByIdResponses, DeleteDataTypeFolderByIdData, DeleteDataTypeFolderByIdErrors, DeleteDataTypeFolderByIdResponses, DeleteDictionaryByIdData, DeleteDictionaryByIdErrors, DeleteDictionaryByIdResponses, DeleteDocumentBlueprintByIdData, DeleteDocumentBlueprintByIdErrors, DeleteDocumentBlueprintByIdResponses, DeleteDocumentBlueprintFolderByIdData, DeleteDocumentBlueprintFolderByIdErrors, DeleteDocumentBlueprintFolderByIdResponses, DeleteDocumentByIdData, DeleteDocumentByIdErrors, DeleteDocumentByIdPublicAccessData, DeleteDocumentByIdPublicAccessErrors, DeleteDocumentByIdPublicAccessResponses, DeleteDocumentByIdResponses, DeleteDocumentTypeByIdData, DeleteDocumentTypeByIdErrors, DeleteDocumentTypeByIdResponses, DeleteDocumentTypeFolderByIdData, DeleteDocumentTypeFolderByIdErrors, DeleteDocumentTypeFolderByIdResponses, DeleteLanguageByIsoCodeData, DeleteLanguageByIsoCodeErrors, DeleteLanguageByIsoCodeResponses, DeleteLogViewerSavedSearchByNameData, DeleteLogViewerSavedSearchByNameErrors, DeleteLogViewerSavedSearchByNameResponses, DeleteMediaByIdData, DeleteMediaByIdErrors, DeleteMediaByIdResponses, DeleteMediaTypeByIdData, DeleteMediaTypeByIdErrors, DeleteMediaTypeByIdResponses, DeleteMediaTypeFolderByIdData, DeleteMediaTypeFolderByIdErrors, DeleteMediaTypeFolderByIdResponses, DeleteMemberByIdData, DeleteMemberByIdErrors, DeleteMemberByIdResponses, DeleteMemberGroupByIdData, DeleteMemberGroupByIdErrors, DeleteMemberGroupByIdResponses, DeleteMemberTypeByIdData, DeleteMemberTypeByIdErrors, DeleteMemberTypeByIdResponses, DeleteMemberTypeFolderByIdData, DeleteMemberTypeFolderByIdErrors, DeleteMemberTypeFolderByIdResponses, DeletePackageCreatedByIdData, DeletePackageCreatedByIdErrors, DeletePackageCreatedByIdResponses, DeletePartialViewByPathData, DeletePartialViewByPathErrors, DeletePartialViewByPathResponses, DeletePartialViewFolderByPathData, DeletePartialViewFolderByPathErrors, DeletePartialViewFolderByPathResponses, DeletePreviewData, DeletePreviewResponses, DeleteRecycleBinDocumentByIdData, DeleteRecycleBinDocumentByIdErrors, DeleteRecycleBinDocumentByIdResponses, DeleteRecycleBinDocumentData, DeleteRecycleBinDocumentErrors, DeleteRecycleBinDocumentResponses, DeleteRecycleBinMediaByIdData, DeleteRecycleBinMediaByIdErrors, DeleteRecycleBinMediaByIdResponses, DeleteRecycleBinMediaData, DeleteRecycleBinMediaErrors, DeleteRecycleBinMediaResponses, DeleteRedirectManagementByIdData, DeleteRedirectManagementByIdErrors, DeleteRedirectManagementByIdResponses, DeleteScriptByPathData, DeleteScriptByPathErrors, DeleteScriptByPathResponses, DeleteScriptFolderByPathData, DeleteScriptFolderByPathErrors, DeleteScriptFolderByPathResponses, DeleteStylesheetByPathData, DeleteStylesheetByPathErrors, DeleteStylesheetByPathResponses, DeleteStylesheetFolderByPathData, DeleteStylesheetFolderByPathErrors, DeleteStylesheetFolderByPathResponses, DeleteTemplateByIdData, DeleteTemplateByIdErrors, DeleteTemplateByIdResponses, DeleteTemporaryFileByIdData, DeleteTemporaryFileByIdErrors, DeleteTemporaryFileByIdResponses, DeleteUserAvatarByIdData, DeleteUserAvatarByIdErrors, DeleteUserAvatarByIdResponses, DeleteUserById2FaByProviderNameData, DeleteUserById2FaByProviderNameErrors, DeleteUserById2FaByProviderNameResponses, DeleteUserByIdClientCredentialsByClientIdData, DeleteUserByIdClientCredentialsByClientIdErrors, DeleteUserByIdClientCredentialsByClientIdResponses, DeleteUserByIdData, DeleteUserByIdErrors, DeleteUserByIdResponses, DeleteUserCurrent2FaByProviderNameData, DeleteUserCurrent2FaByProviderNameErrors, DeleteUserCurrent2FaByProviderNameResponses, DeleteUserData, DeleteUserDataByIdData, DeleteUserDataByIdErrors, DeleteUserDataByIdResponses, DeleteUserErrors, DeleteUserGroupByIdData, DeleteUserGroupByIdErrors, DeleteUserGroupByIdResponses, DeleteUserGroupByIdUsersData, DeleteUserGroupByIdUsersErrors, DeleteUserGroupByIdUsersResponses, DeleteUserGroupData, DeleteUserGroupErrors, DeleteUserGroupResponses, DeleteUserResponses, DeleteWebhookByIdData, DeleteWebhookByIdErrors, DeleteWebhookByIdResponses, GetCollectionDocumentByIdData, GetCollectionDocumentByIdErrors, GetCollectionDocumentByIdResponses, GetCollectionMediaData, GetCollectionMediaErrors, GetCollectionMediaResponses, GetCultureData, GetCultureErrors, GetCultureResponses, GetDataTypeBatchData, GetDataTypeBatchErrors, GetDataTypeBatchResponses, GetDataTypeByIdData, GetDataTypeByIdErrors, GetDataTypeByIdIsUsedData, GetDataTypeByIdIsUsedErrors, GetDataTypeByIdIsUsedResponses, GetDataTypeByIdReferencedByData, GetDataTypeByIdReferencedByErrors, GetDataTypeByIdReferencedByResponses, GetDataTypeByIdResponses, GetDataTypeByIdSchemaData, GetDataTypeByIdSchemaErrors, GetDataTypeByIdSchemaResponses, GetDataTypeConfigurationData, GetDataTypeConfigurationErrors, GetDataTypeConfigurationResponses, GetDataTypeFolderByIdData, GetDataTypeFolderByIdErrors, GetDataTypeFolderByIdResponses, GetDataTypeSchemasBatchData, GetDataTypeSchemasBatchErrors, GetDataTypeSchemasBatchResponses, GetDictionaryByIdData, GetDictionaryByIdErrors, GetDictionaryByIdExportData, GetDictionaryByIdExportErrors, GetDictionaryByIdExportResponses, GetDictionaryByIdResponses, GetDictionaryData, GetDictionaryErrors, GetDictionaryResponses, GetDocumentAreReferencedData, GetDocumentAreReferencedErrors, GetDocumentAreReferencedResponses, GetDocumentBlueprintByIdAuditLogData, GetDocumentBlueprintByIdAuditLogErrors, GetDocumentBlueprintByIdAuditLogResponses, GetDocumentBlueprintByIdData, GetDocumentBlueprintByIdErrors, GetDocumentBlueprintByIdResponses, GetDocumentBlueprintByIdScaffoldData, GetDocumentBlueprintByIdScaffoldErrors, GetDocumentBlueprintByIdScaffoldResponses, GetDocumentBlueprintFolderByIdData, GetDocumentBlueprintFolderByIdErrors, GetDocumentBlueprintFolderByIdResponses, GetDocumentByIdAuditLogData, GetDocumentByIdAuditLogErrors, GetDocumentByIdAuditLogResponses, GetDocumentByIdAvailableSegmentOptionsData, GetDocumentByIdAvailableSegmentOptionsErrors, GetDocumentByIdAvailableSegmentOptionsResponses, GetDocumentByIdData, GetDocumentByIdDomainsData, GetDocumentByIdDomainsErrors, GetDocumentByIdDomainsResponses, GetDocumentByIdErrors, GetDocumentByIdNotificationsData, GetDocumentByIdNotificationsErrors, GetDocumentByIdNotificationsResponses, GetDocumentByIdPreviewUrlData, GetDocumentByIdPreviewUrlErrors, GetDocumentByIdPreviewUrlResponses, GetDocumentByIdPublicAccessData, GetDocumentByIdPublicAccessErrors, GetDocumentByIdPublicAccessResponses, GetDocumentByIdPublishedData, GetDocumentByIdPublishedErrors, GetDocumentByIdPublishedResponses, GetDocumentByIdPublishWithDescendantsResultByTaskIdData, GetDocumentByIdPublishWithDescendantsResultByTaskIdErrors, GetDocumentByIdPublishWithDescendantsResultByTaskIdResponses, GetDocumentByIdReferencedByData, GetDocumentByIdReferencedByErrors, GetDocumentByIdReferencedByResponses, GetDocumentByIdReferencedDescendantsData, GetDocumentByIdReferencedDescendantsErrors, GetDocumentByIdReferencedDescendantsResponses, GetDocumentByIdResponses, GetDocumentConfigurationData, GetDocumentConfigurationErrors, GetDocumentConfigurationResponses, GetDocumentTypeAllowedAtRootData, GetDocumentTypeAllowedAtRootErrors, GetDocumentTypeAllowedAtRootResponses, GetDocumentTypeBatchData, GetDocumentTypeBatchErrors, GetDocumentTypeBatchResponses, GetDocumentTypeByIdAllowedChildrenData, GetDocumentTypeByIdAllowedChildrenErrors, GetDocumentTypeByIdAllowedChildrenResponses, GetDocumentTypeByIdAllowedParentsData, GetDocumentTypeByIdAllowedParentsErrors, GetDocumentTypeByIdAllowedParentsResponses, GetDocumentTypeByIdBlueprintData, GetDocumentTypeByIdBlueprintErrors, GetDocumentTypeByIdBlueprintResponses, GetDocumentTypeByIdCompositionReferencesData, GetDocumentTypeByIdCompositionReferencesErrors, GetDocumentTypeByIdCompositionReferencesResponses, GetDocumentTypeByIdData, GetDocumentTypeByIdErrors, GetDocumentTypeByIdExportData, GetDocumentTypeByIdExportErrors, GetDocumentTypeByIdExportResponses, GetDocumentTypeByIdResponses, GetDocumentTypeByIdSchemaData, GetDocumentTypeByIdSchemaErrors, GetDocumentTypeByIdSchemaResponses, GetDocumentTypeConfigurationData, GetDocumentTypeConfigurationErrors, GetDocumentTypeConfigurationResponses, GetDocumentTypeFolderByIdData, GetDocumentTypeFolderByIdErrors, GetDocumentTypeFolderByIdResponses, GetDocumentUrlsData, GetDocumentUrlsErrors, GetDocumentUrlsResponses, GetDocumentVersionByIdData, GetDocumentVersionByIdErrors, GetDocumentVersionByIdResponses, GetDocumentVersionData, GetDocumentVersionErrors, GetDocumentVersionResponses, GetDynamicRootStepsData, GetDynamicRootStepsErrors, GetDynamicRootStepsResponses, GetFilterDataTypeData, GetFilterDataTypeErrors, GetFilterDataTypeResponses, GetFilterMemberData, GetFilterMemberErrors, GetFilterMemberResponses, GetFilterUserData, GetFilterUserErrors, GetFilterUserGroupData, GetFilterUserGroupErrors, GetFilterUserGroupResponses, GetFilterUserResponses, GetHealthCheckGroupByNameData, GetHealthCheckGroupByNameErrors, GetHealthCheckGroupByNameResponses, GetHealthCheckGroupData, GetHealthCheckGroupErrors, GetHealthCheckGroupResponses, GetHelpData, GetHelpErrors, GetHelpResponses, GetImagingResizeUrlsData, GetImagingResizeUrlsErrors, GetImagingResizeUrlsResponses, GetImportAnalyzeData, GetImportAnalyzeErrors, GetImportAnalyzeResponses, GetIndexerByIndexNameData, GetIndexerByIndexNameErrors, GetIndexerByIndexNameResponses, GetIndexerData, GetIndexerErrors, GetIndexerResponses, GetInstallSettingsData, GetInstallSettingsErrors, GetInstallSettingsResponses, GetItemDataTypeAncestorsData, GetItemDataTypeAncestorsErrors, GetItemDataTypeAncestorsResponses, GetItemDataTypeData, GetItemDataTypeErrors, GetItemDataTypeResponses, GetItemDataTypeSearchData, GetItemDataTypeSearchErrors, GetItemDataTypeSearchResponses, GetItemDictionaryData, GetItemDictionaryErrors, GetItemDictionaryResponses, GetItemDocumentAncestorsData, GetItemDocumentAncestorsErrors, GetItemDocumentAncestorsResponses, GetItemDocumentBlueprintData, GetItemDocumentBlueprintErrors, GetItemDocumentBlueprintResponses, GetItemDocumentData, GetItemDocumentErrors, GetItemDocumentResponses, GetItemDocumentSearchData, GetItemDocumentSearchErrors, GetItemDocumentSearchResponses, GetItemDocumentTypeAncestorsData, GetItemDocumentTypeAncestorsErrors, GetItemDocumentTypeAncestorsResponses, GetItemDocumentTypeData, GetItemDocumentTypeErrors, GetItemDocumentTypeResponses, GetItemDocumentTypeSearchData, GetItemDocumentTypeSearchErrors, GetItemDocumentTypeSearchResponses, GetItemLanguageData, GetItemLanguageDefaultData, GetItemLanguageDefaultErrors, GetItemLanguageDefaultResponses, GetItemLanguageErrors, GetItemLanguageResponses, GetItemMediaAncestorsData, GetItemMediaAncestorsErrors, GetItemMediaAncestorsResponses, GetItemMediaData, GetItemMediaErrors, GetItemMediaResponses, GetItemMediaSearchData, GetItemMediaSearchErrors, GetItemMediaSearchResponses, GetItemMediaTypeAllowedData, GetItemMediaTypeAllowedErrors, GetItemMediaTypeAllowedResponses, GetItemMediaTypeAncestorsData, GetItemMediaTypeAncestorsErrors, GetItemMediaTypeAncestorsResponses, GetItemMediaTypeData, GetItemMediaTypeErrors, GetItemMediaTypeFoldersData, GetItemMediaTypeFoldersErrors, GetItemMediaTypeFoldersResponses, GetItemMediaTypeResponses, GetItemMediaTypeSearchData, GetItemMediaTypeSearchErrors, GetItemMediaTypeSearchResponses, GetItemMemberAncestorsData, GetItemMemberAncestorsErrors, GetItemMemberAncestorsResponses, GetItemMemberData, GetItemMemberErrors, GetItemMemberGroupData, GetItemMemberGroupErrors, GetItemMemberGroupResponses, GetItemMemberResponses, GetItemMemberSearchData, GetItemMemberSearchErrors, GetItemMemberSearchResponses, GetItemMemberTypeAncestorsData, GetItemMemberTypeAncestorsErrors, GetItemMemberTypeAncestorsResponses, GetItemMemberTypeData, GetItemMemberTypeErrors, GetItemMemberTypeResponses, GetItemMemberTypeSearchData, GetItemMemberTypeSearchErrors, GetItemMemberTypeSearchResponses, GetItemPartialViewData, GetItemPartialViewErrors, GetItemPartialViewResponses, GetItemRelationTypeData, GetItemRelationTypeErrors, GetItemRelationTypeResponses, GetItemScriptData, GetItemScriptErrors, GetItemScriptResponses, GetItemStaticFileData, GetItemStaticFileErrors, GetItemStaticFileResponses, GetItemStylesheetData, GetItemStylesheetErrors, GetItemStylesheetResponses, GetItemTemplateAncestorsData, GetItemTemplateAncestorsErrors, GetItemTemplateAncestorsResponses, GetItemTemplateData, GetItemTemplateErrors, GetItemTemplateResponses, GetItemTemplateSearchData, GetItemTemplateSearchErrors, GetItemTemplateSearchResponses, GetItemUserData, GetItemUserErrors, GetItemUserGroupData, GetItemUserGroupErrors, GetItemUserGroupResponses, GetItemUserResponses, GetItemWebhookData, GetItemWebhookErrors, GetItemWebhookResponses, GetLanguageByIsoCodeData, GetLanguageByIsoCodeErrors, GetLanguageByIsoCodeResponses, GetLanguageData, GetLanguageErrors, GetLanguageResponses, GetLogViewerLevelCountData, GetLogViewerLevelCountErrors, GetLogViewerLevelCountResponses, GetLogViewerLevelData, GetLogViewerLevelErrors, GetLogViewerLevelResponses, GetLogViewerLogData, GetLogViewerLogErrors, GetLogViewerLogResponses, GetLogViewerMessageTemplateData, GetLogViewerMessageTemplateErrors, GetLogViewerMessageTemplateResponses, GetLogViewerSavedSearchByNameData, GetLogViewerSavedSearchByNameErrors, GetLogViewerSavedSearchByNameResponses, GetLogViewerSavedSearchData, GetLogViewerSavedSearchErrors, GetLogViewerSavedSearchResponses, GetLogViewerValidateLogsSizeData, GetLogViewerValidateLogsSizeErrors, GetLogViewerValidateLogsSizeResponses, GetManifestManifestData, GetManifestManifestErrors, GetManifestManifestPrivateData, GetManifestManifestPrivateErrors, GetManifestManifestPrivateResponses, GetManifestManifestPublicData, GetManifestManifestPublicResponses, GetManifestManifestResponses, GetMediaAreReferencedData, GetMediaAreReferencedErrors, GetMediaAreReferencedResponses, GetMediaByIdAuditLogData, GetMediaByIdAuditLogErrors, GetMediaByIdAuditLogResponses, GetMediaByIdData, GetMediaByIdErrors, GetMediaByIdReferencedByData, GetMediaByIdReferencedByErrors, GetMediaByIdReferencedByResponses, GetMediaByIdReferencedDescendantsData, GetMediaByIdReferencedDescendantsErrors, GetMediaByIdReferencedDescendantsResponses, GetMediaByIdResponses, GetMediaConfigurationData, GetMediaConfigurationErrors, GetMediaConfigurationResponses, GetMediaTypeAllowedAtRootData, GetMediaTypeAllowedAtRootErrors, GetMediaTypeAllowedAtRootResponses, GetMediaTypeBatchData, GetMediaTypeBatchErrors, GetMediaTypeBatchResponses, GetMediaTypeByIdAllowedChildrenData, GetMediaTypeByIdAllowedChildrenErrors, GetMediaTypeByIdAllowedChildrenResponses, GetMediaTypeByIdAllowedParentsData, GetMediaTypeByIdAllowedParentsErrors, GetMediaTypeByIdAllowedParentsResponses, GetMediaTypeByIdCompositionReferencesData, GetMediaTypeByIdCompositionReferencesErrors, GetMediaTypeByIdCompositionReferencesResponses, GetMediaTypeByIdData, GetMediaTypeByIdErrors, GetMediaTypeByIdExportData, GetMediaTypeByIdExportErrors, GetMediaTypeByIdExportResponses, GetMediaTypeByIdResponses, GetMediaTypeByIdSchemaData, GetMediaTypeByIdSchemaErrors, GetMediaTypeByIdSchemaResponses, GetMediaTypeConfigurationData, GetMediaTypeConfigurationErrors, GetMediaTypeConfigurationResponses, GetMediaTypeFolderByIdData, GetMediaTypeFolderByIdErrors, GetMediaTypeFolderByIdResponses, GetMediaUrlsData, GetMediaUrlsErrors, GetMediaUrlsResponses, GetMemberAreReferencedData, GetMemberAreReferencedErrors, GetMemberAreReferencedResponses, GetMemberByIdData, GetMemberByIdErrors, GetMemberByIdReferencedByData, GetMemberByIdReferencedByErrors, GetMemberByIdReferencedByResponses, GetMemberByIdReferencedDescendantsData, GetMemberByIdReferencedDescendantsErrors, GetMemberByIdReferencedDescendantsResponses, GetMemberByIdResponses, GetMemberConfigurationData, GetMemberConfigurationErrors, GetMemberConfigurationResponses, GetMemberGroupByIdData, GetMemberGroupByIdErrors, GetMemberGroupByIdResponses, GetMemberGroupData, GetMemberGroupErrors, GetMemberGroupResponses, GetMemberTypeAllowedAtRootData, GetMemberTypeAllowedAtRootErrors, GetMemberTypeAllowedAtRootResponses, GetMemberTypeBatchData, GetMemberTypeBatchErrors, GetMemberTypeBatchResponses, GetMemberTypeByIdCompositionReferencesData, GetMemberTypeByIdCompositionReferencesErrors, GetMemberTypeByIdCompositionReferencesResponses, GetMemberTypeByIdData, GetMemberTypeByIdErrors, GetMemberTypeByIdExportData, GetMemberTypeByIdExportErrors, GetMemberTypeByIdExportResponses, GetMemberTypeByIdResponses, GetMemberTypeByIdSchemaData, GetMemberTypeByIdSchemaErrors, GetMemberTypeByIdSchemaResponses, GetMemberTypeConfigurationData, GetMemberTypeConfigurationErrors, GetMemberTypeConfigurationResponses, GetMemberTypeFolderByIdData, GetMemberTypeFolderByIdErrors, GetMemberTypeFolderByIdResponses, GetModelsBuilderDashboardData, GetModelsBuilderDashboardErrors, GetModelsBuilderDashboardResponses, GetModelsBuilderStatusData, GetModelsBuilderStatusErrors, GetModelsBuilderStatusResponses, GetNewsDashboardData, GetNewsDashboardErrors, GetNewsDashboardResponses, GetObjectTypesData, GetObjectTypesErrors, GetObjectTypesResponses, GetOembedQueryData, GetOembedQueryErrors, GetOembedQueryResponses, GetPackageConfigurationData, GetPackageConfigurationErrors, GetPackageConfigurationResponses, GetPackageCreatedByIdData, GetPackageCreatedByIdDownloadData, GetPackageCreatedByIdDownloadErrors, GetPackageCreatedByIdDownloadResponses, GetPackageCreatedByIdErrors, GetPackageCreatedByIdResponses, GetPackageCreatedData, GetPackageCreatedErrors, GetPackageCreatedResponses, GetPackageMigrationStatusData, GetPackageMigrationStatusErrors, GetPackageMigrationStatusResponses, GetPartialViewByPathData, GetPartialViewByPathErrors, GetPartialViewByPathResponses, GetPartialViewFolderByPathData, GetPartialViewFolderByPathErrors, GetPartialViewFolderByPathResponses, GetPartialViewSnippetByIdData, GetPartialViewSnippetByIdErrors, GetPartialViewSnippetByIdResponses, GetPartialViewSnippetData, GetPartialViewSnippetErrors, GetPartialViewSnippetResponses, GetProfilingStatusData, GetProfilingStatusErrors, GetProfilingStatusResponses, GetPropertyTypeIsUsedData, GetPropertyTypeIsUsedErrors, GetPropertyTypeIsUsedResponses, GetPublishedCacheRebuildStatusData, GetPublishedCacheRebuildStatusErrors, GetPublishedCacheRebuildStatusResponses, GetRecycleBinDocumentByIdOriginalParentData, GetRecycleBinDocumentByIdOriginalParentErrors, GetRecycleBinDocumentByIdOriginalParentResponses, GetRecycleBinDocumentChildrenData, GetRecycleBinDocumentChildrenErrors, GetRecycleBinDocumentChildrenResponses, GetRecycleBinDocumentReferencedByData, GetRecycleBinDocumentReferencedByErrors, GetRecycleBinDocumentReferencedByResponses, GetRecycleBinDocumentRootData, GetRecycleBinDocumentRootErrors, GetRecycleBinDocumentRootResponses, GetRecycleBinDocumentSiblingsData, GetRecycleBinDocumentSiblingsErrors, GetRecycleBinDocumentSiblingsResponses, GetRecycleBinMediaByIdOriginalParentData, GetRecycleBinMediaByIdOriginalParentErrors, GetRecycleBinMediaByIdOriginalParentResponses, GetRecycleBinMediaChildrenData, GetRecycleBinMediaChildrenErrors, GetRecycleBinMediaChildrenResponses, GetRecycleBinMediaReferencedByData, GetRecycleBinMediaReferencedByErrors, GetRecycleBinMediaReferencedByResponses, GetRecycleBinMediaRootData, GetRecycleBinMediaRootErrors, GetRecycleBinMediaRootResponses, GetRecycleBinMediaSiblingsData, GetRecycleBinMediaSiblingsErrors, GetRecycleBinMediaSiblingsResponses, GetRedirectManagementByIdData, GetRedirectManagementByIdErrors, GetRedirectManagementByIdResponses, GetRedirectManagementData, GetRedirectManagementErrors, GetRedirectManagementResponses, GetRedirectManagementStatusData, GetRedirectManagementStatusErrors, GetRedirectManagementStatusResponses, GetRelationByRelationTypeIdData, GetRelationByRelationTypeIdErrors, GetRelationByRelationTypeIdResponses, GetRelationTypeByIdData, GetRelationTypeByIdErrors, GetRelationTypeByIdResponses, GetRelationTypeData, GetRelationTypeErrors, GetRelationTypeResponses, GetScriptByPathData, GetScriptByPathErrors, GetScriptByPathResponses, GetScriptFolderByPathData, GetScriptFolderByPathErrors, GetScriptFolderByPathResponses, GetSearcherBySearcherNameQueryData, GetSearcherBySearcherNameQueryErrors, GetSearcherBySearcherNameQueryResponses, GetSearcherData, GetSearcherErrors, GetSearcherResponses, GetSecurityConfigurationData, GetSecurityConfigurationErrors, GetSecurityConfigurationResponses, GetSegmentData, GetSegmentErrors, GetSegmentResponses, GetServerConfigurationData, GetServerConfigurationResponses, GetServerInformationData, GetServerInformationErrors, GetServerInformationResponses, GetServerStatusData, GetServerStatusErrors, GetServerStatusResponses, GetServerTroubleshootingData, GetServerTroubleshootingErrors, GetServerTroubleshootingResponses, GetServerUpgradeCheckData, GetServerUpgradeCheckErrors, GetServerUpgradeCheckResponses, GetStylesheetByPathData, GetStylesheetByPathErrors, GetStylesheetByPathResponses, GetStylesheetFolderByPathData, GetStylesheetFolderByPathErrors, GetStylesheetFolderByPathResponses, GetTagData, GetTagErrors, GetTagResponses, GetTelemetryData, GetTelemetryErrors, GetTelemetryLevelData, GetTelemetryLevelErrors, GetTelemetryLevelResponses, GetTelemetryResponses, GetTemplateByIdData, GetTemplateByIdErrors, GetTemplateByIdResponses, GetTemplateConfigurationData, GetTemplateConfigurationErrors, GetTemplateConfigurationResponses, GetTemplateQuerySettingsData, GetTemplateQuerySettingsErrors, GetTemplateQuerySettingsResponses, GetTemporaryFileByIdData, GetTemporaryFileByIdErrors, GetTemporaryFileByIdResponses, GetTemporaryFileConfigurationData, GetTemporaryFileConfigurationErrors, GetTemporaryFileConfigurationResponses, GetTreeDataTypeAncestorsData, GetTreeDataTypeAncestorsErrors, GetTreeDataTypeAncestorsResponses, GetTreeDataTypeChildrenData, GetTreeDataTypeChildrenErrors, GetTreeDataTypeChildrenResponses, GetTreeDataTypeRootData, GetTreeDataTypeRootErrors, GetTreeDataTypeRootResponses, GetTreeDataTypeSearchData, GetTreeDataTypeSearchErrors, GetTreeDataTypeSearchResponses, GetTreeDataTypeSiblingsData, GetTreeDataTypeSiblingsErrors, GetTreeDataTypeSiblingsResponses, GetTreeDictionaryAncestorsData, GetTreeDictionaryAncestorsErrors, GetTreeDictionaryAncestorsResponses, GetTreeDictionaryChildrenData, GetTreeDictionaryChildrenErrors, GetTreeDictionaryChildrenResponses, GetTreeDictionaryRootData, GetTreeDictionaryRootErrors, GetTreeDictionaryRootResponses, GetTreeDocumentAncestorsData, GetTreeDocumentAncestorsErrors, GetTreeDocumentAncestorsResponses, GetTreeDocumentBlueprintAncestorsData, GetTreeDocumentBlueprintAncestorsErrors, GetTreeDocumentBlueprintAncestorsResponses, GetTreeDocumentBlueprintChildrenData, GetTreeDocumentBlueprintChildrenErrors, GetTreeDocumentBlueprintChildrenResponses, GetTreeDocumentBlueprintRootData, GetTreeDocumentBlueprintRootErrors, GetTreeDocumentBlueprintRootResponses, GetTreeDocumentBlueprintSiblingsData, GetTreeDocumentBlueprintSiblingsErrors, GetTreeDocumentBlueprintSiblingsResponses, GetTreeDocumentChildrenData, GetTreeDocumentChildrenErrors, GetTreeDocumentChildrenResponses, GetTreeDocumentRootData, GetTreeDocumentRootErrors, GetTreeDocumentRootResponses, GetTreeDocumentSiblingsData, GetTreeDocumentSiblingsErrors, GetTreeDocumentSiblingsResponses, GetTreeDocumentTypeAncestorsData, GetTreeDocumentTypeAncestorsErrors, GetTreeDocumentTypeAncestorsResponses, GetTreeDocumentTypeChildrenData, GetTreeDocumentTypeChildrenErrors, GetTreeDocumentTypeChildrenResponses, GetTreeDocumentTypeRootData, GetTreeDocumentTypeRootErrors, GetTreeDocumentTypeRootResponses, GetTreeDocumentTypeSearchData, GetTreeDocumentTypeSearchErrors, GetTreeDocumentTypeSearchResponses, GetTreeDocumentTypeSiblingsData, GetTreeDocumentTypeSiblingsErrors, GetTreeDocumentTypeSiblingsResponses, GetTreeMediaAncestorsData, GetTreeMediaAncestorsErrors, GetTreeMediaAncestorsResponses, GetTreeMediaChildrenData, GetTreeMediaChildrenErrors, GetTreeMediaChildrenResponses, GetTreeMediaRootData, GetTreeMediaRootErrors, GetTreeMediaRootResponses, GetTreeMediaSiblingsData, GetTreeMediaSiblingsErrors, GetTreeMediaSiblingsResponses, GetTreeMediaTypeAncestorsData, GetTreeMediaTypeAncestorsErrors, GetTreeMediaTypeAncestorsResponses, GetTreeMediaTypeChildrenData, GetTreeMediaTypeChildrenErrors, GetTreeMediaTypeChildrenResponses, GetTreeMediaTypeRootData, GetTreeMediaTypeRootErrors, GetTreeMediaTypeRootResponses, GetTreeMediaTypeSiblingsData, GetTreeMediaTypeSiblingsErrors, GetTreeMediaTypeSiblingsResponses, GetTreeMemberGroupRootData, GetTreeMemberGroupRootErrors, GetTreeMemberGroupRootResponses, GetTreeMemberTypeAncestorsData, GetTreeMemberTypeAncestorsErrors, GetTreeMemberTypeAncestorsResponses, GetTreeMemberTypeChildrenData, GetTreeMemberTypeChildrenErrors, GetTreeMemberTypeChildrenResponses, GetTreeMemberTypeRootData, GetTreeMemberTypeRootErrors, GetTreeMemberTypeRootResponses, GetTreeMemberTypeSiblingsData, GetTreeMemberTypeSiblingsErrors, GetTreeMemberTypeSiblingsResponses, GetTreePartialViewAncestorsData, GetTreePartialViewAncestorsErrors, GetTreePartialViewAncestorsResponses, GetTreePartialViewChildrenData, GetTreePartialViewChildrenErrors, GetTreePartialViewChildrenResponses, GetTreePartialViewRootData, GetTreePartialViewRootErrors, GetTreePartialViewRootResponses, GetTreePartialViewSiblingsData, GetTreePartialViewSiblingsErrors, GetTreePartialViewSiblingsResponses, GetTreeScriptAncestorsData, GetTreeScriptAncestorsErrors, GetTreeScriptAncestorsResponses, GetTreeScriptChildrenData, GetTreeScriptChildrenErrors, GetTreeScriptChildrenResponses, GetTreeScriptRootData, GetTreeScriptRootErrors, GetTreeScriptRootResponses, GetTreeScriptSiblingsData, GetTreeScriptSiblingsErrors, GetTreeScriptSiblingsResponses, GetTreeStaticFileAncestorsData, GetTreeStaticFileAncestorsErrors, GetTreeStaticFileAncestorsResponses, GetTreeStaticFileChildrenData, GetTreeStaticFileChildrenErrors, GetTreeStaticFileChildrenResponses, GetTreeStaticFileRootData, GetTreeStaticFileRootErrors, GetTreeStaticFileRootResponses, GetTreeStylesheetAncestorsData, GetTreeStylesheetAncestorsErrors, GetTreeStylesheetAncestorsResponses, GetTreeStylesheetChildrenData, GetTreeStylesheetChildrenErrors, GetTreeStylesheetChildrenResponses, GetTreeStylesheetRootData, GetTreeStylesheetRootErrors, GetTreeStylesheetRootResponses, GetTreeStylesheetSiblingsData, GetTreeStylesheetSiblingsErrors, GetTreeStylesheetSiblingsResponses, GetTreeTemplateAncestorsData, GetTreeTemplateAncestorsErrors, GetTreeTemplateAncestorsResponses, GetTreeTemplateChildrenData, GetTreeTemplateChildrenErrors, GetTreeTemplateChildrenResponses, GetTreeTemplateRootData, GetTreeTemplateRootErrors, GetTreeTemplateRootResponses, GetTreeTemplateSiblingsData, GetTreeTemplateSiblingsErrors, GetTreeTemplateSiblingsResponses, GetUpgradeSettingsData, GetUpgradeSettingsErrors, GetUpgradeSettingsResponses, GetUserById2FaData, GetUserById2FaErrors, GetUserById2FaResponses, GetUserByIdCalculateStartNodesData, GetUserByIdCalculateStartNodesErrors, GetUserByIdCalculateStartNodesResponses, GetUserByIdClientCredentialsData, GetUserByIdClientCredentialsErrors, GetUserByIdClientCredentialsResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, GetUserConfigurationData, GetUserConfigurationErrors, GetUserConfigurationResponses, GetUserCurrent2FaByProviderNameData, GetUserCurrent2FaByProviderNameErrors, GetUserCurrent2FaByProviderNameResponses, GetUserCurrent2FaData, GetUserCurrent2FaErrors, GetUserCurrent2FaResponses, GetUserCurrentConfigurationData, GetUserCurrentConfigurationErrors, GetUserCurrentConfigurationResponses, GetUserCurrentData, GetUserCurrentErrors, GetUserCurrentLoginProvidersData, GetUserCurrentLoginProvidersErrors, GetUserCurrentLoginProvidersResponses, GetUserCurrentPermissionsData, GetUserCurrentPermissionsDocumentData, GetUserCurrentPermissionsDocumentErrors, GetUserCurrentPermissionsDocumentResponses, GetUserCurrentPermissionsErrors, GetUserCurrentPermissionsMediaData, GetUserCurrentPermissionsMediaErrors, GetUserCurrentPermissionsMediaResponses, GetUserCurrentPermissionsResponses, GetUserCurrentResponses, GetUserData, GetUserDataByIdData, GetUserDataByIdErrors, GetUserDataByIdResponses, GetUserDataData, GetUserDataErrors, GetUserDataResponses, GetUserErrors, GetUserGroupByIdData, GetUserGroupByIdErrors, GetUserGroupByIdResponses, GetUserGroupData, GetUserGroupErrors, GetUserGroupResponses, GetUserResponses, GetWebhookByIdData, GetWebhookByIdErrors, GetWebhookByIdLogsData, GetWebhookByIdLogsErrors, GetWebhookByIdLogsResponses, GetWebhookByIdResponses, GetWebhookData, GetWebhookErrors, GetWebhookEventsData, GetWebhookEventsErrors, GetWebhookEventsResponses, GetWebhookLogsData, GetWebhookLogsErrors, GetWebhookLogsResponses, GetWebhookResponses, PatchDocumentByIdPatchData, PatchDocumentByIdPatchErrors, PatchDocumentByIdPatchResponses, PostDataTypeByIdCopyData, PostDataTypeByIdCopyErrors, PostDataTypeByIdCopyResponses, PostDataTypeData, PostDataTypeErrors, PostDataTypeFolderData, PostDataTypeFolderErrors, PostDataTypeFolderResponses, PostDataTypeResponses, PostDictionaryData, PostDictionaryErrors, PostDictionaryImportData, PostDictionaryImportErrors, PostDictionaryImportResponses, PostDictionaryResponses, PostDocumentBlueprintData, PostDocumentBlueprintErrors, PostDocumentBlueprintFolderData, PostDocumentBlueprintFolderErrors, PostDocumentBlueprintFolderResponses, PostDocumentBlueprintFromDocumentData, PostDocumentBlueprintFromDocumentErrors, PostDocumentBlueprintFromDocumentResponses, PostDocumentBlueprintResponses, PostDocumentByIdCopyData, PostDocumentByIdCopyErrors, PostDocumentByIdCopyResponses, PostDocumentByIdPublicAccessData, PostDocumentByIdPublicAccessErrors, PostDocumentByIdPublicAccessResponses, PostDocumentData, PostDocumentErrors, PostDocumentResponses, PostDocumentTypeAvailableCompositionsData, PostDocumentTypeAvailableCompositionsErrors, PostDocumentTypeAvailableCompositionsResponses, PostDocumentTypeByIdCopyData, PostDocumentTypeByIdCopyErrors, PostDocumentTypeByIdCopyResponses, PostDocumentTypeByIdTemplateData, PostDocumentTypeByIdTemplateErrors, PostDocumentTypeByIdTemplateResponses, PostDocumentTypeData, PostDocumentTypeErrors, PostDocumentTypeFolderData, PostDocumentTypeFolderErrors, PostDocumentTypeFolderResponses, PostDocumentTypeImportData, PostDocumentTypeImportErrors, PostDocumentTypeImportResponses, PostDocumentTypeResponses, PostDocumentValidateData, PostDocumentValidateErrors, PostDocumentValidateResponses, PostDocumentVersionByIdRollbackData, PostDocumentVersionByIdRollbackErrors, PostDocumentVersionByIdRollbackResponses, PostDynamicRootQueryData, PostDynamicRootQueryErrors, PostDynamicRootQueryResponses, PostHealthCheckExecuteActionData, PostHealthCheckExecuteActionErrors, PostHealthCheckExecuteActionResponses, PostHealthCheckGroupByNameCheckData, PostHealthCheckGroupByNameCheckErrors, PostHealthCheckGroupByNameCheckResponses, PostIndexerByIndexNameRebuildData, PostIndexerByIndexNameRebuildErrors, PostIndexerByIndexNameRebuildResponses, PostInstallSetupData, PostInstallSetupErrors, PostInstallSetupResponses, PostInstallValidateDatabaseData, PostInstallValidateDatabaseErrors, PostInstallValidateDatabaseResponses, PostLanguageData, PostLanguageErrors, PostLanguageResponses, PostLogViewerSavedSearchData, PostLogViewerSavedSearchErrors, PostLogViewerSavedSearchResponses, PostMediaData, PostMediaErrors, PostMediaResponses, PostMediaTypeAvailableCompositionsData, PostMediaTypeAvailableCompositionsErrors, PostMediaTypeAvailableCompositionsResponses, PostMediaTypeByIdCopyData, PostMediaTypeByIdCopyErrors, PostMediaTypeByIdCopyResponses, PostMediaTypeData, PostMediaTypeErrors, PostMediaTypeFolderData, PostMediaTypeFolderErrors, PostMediaTypeFolderResponses, PostMediaTypeImportData, PostMediaTypeImportErrors, PostMediaTypeImportResponses, PostMediaTypeResponses, PostMediaValidateData, PostMediaValidateErrors, PostMediaValidateResponses, PostMemberData, PostMemberErrors, PostMemberGroupData, PostMemberGroupErrors, PostMemberGroupResponses, PostMemberResponses, PostMemberTypeAvailableCompositionsData, PostMemberTypeAvailableCompositionsErrors, PostMemberTypeAvailableCompositionsResponses, PostMemberTypeByIdCopyData, PostMemberTypeByIdCopyErrors, PostMemberTypeByIdCopyResponses, PostMemberTypeData, PostMemberTypeErrors, PostMemberTypeFolderData, PostMemberTypeFolderErrors, PostMemberTypeFolderResponses, PostMemberTypeImportData, PostMemberTypeImportErrors, PostMemberTypeImportResponses, PostMemberTypeResponses, PostMemberValidateData, PostMemberValidateErrors, PostMemberValidateResponses, PostModelsBuilderBuildData, PostModelsBuilderBuildErrors, PostModelsBuilderBuildResponses, PostPackageByNameRunMigrationData, PostPackageByNameRunMigrationErrors, PostPackageByNameRunMigrationResponses, PostPackageCreatedData, PostPackageCreatedErrors, PostPackageCreatedResponses, PostPartialViewData, PostPartialViewErrors, PostPartialViewFolderData, PostPartialViewFolderErrors, PostPartialViewFolderResponses, PostPartialViewResponses, PostPreviewData, PostPreviewErrors, PostPreviewResponses, PostPublishedCacheRebuildData, PostPublishedCacheRebuildErrors, PostPublishedCacheRebuildResponses, PostPublishedCacheReloadData, PostPublishedCacheReloadErrors, PostPublishedCacheReloadResponses, PostRedirectManagementStatusData, PostRedirectManagementStatusErrors, PostRedirectManagementStatusResponses, PostScriptData, PostScriptErrors, PostScriptFolderData, PostScriptFolderErrors, PostScriptFolderResponses, PostScriptResponses, PostSecurityForgotPasswordData, PostSecurityForgotPasswordErrors, PostSecurityForgotPasswordResetData, PostSecurityForgotPasswordResetErrors, PostSecurityForgotPasswordResetResponses, PostSecurityForgotPasswordResponses, PostSecurityForgotPasswordVerifyData, PostSecurityForgotPasswordVerifyErrors, PostSecurityForgotPasswordVerifyResponses, PostStylesheetData, PostStylesheetErrors, PostStylesheetFolderData, PostStylesheetFolderErrors, PostStylesheetFolderResponses, PostStylesheetResponses, PostTelemetryLevelData, PostTelemetryLevelErrors, PostTelemetryLevelResponses, PostTemplateData, PostTemplateErrors, PostTemplateQueryExecuteData, PostTemplateQueryExecuteErrors, PostTemplateQueryExecuteResponses, PostTemplateResponses, PostTemporaryFileData, PostTemporaryFileErrors, PostTemporaryFileResponses, PostUpgradeAuthorizeData, PostUpgradeAuthorizeErrors, PostUpgradeAuthorizeResponses, PostUserAvatarByIdData, PostUserAvatarByIdErrors, PostUserAvatarByIdResponses, PostUserByIdChangePasswordData, PostUserByIdChangePasswordErrors, PostUserByIdChangePasswordResponses, PostUserByIdClientCredentialsData, PostUserByIdClientCredentialsErrors, PostUserByIdClientCredentialsResponses, PostUserByIdResetPasswordData, PostUserByIdResetPasswordErrors, PostUserByIdResetPasswordResponses, PostUserCurrent2FaByProviderNameData, PostUserCurrent2FaByProviderNameErrors, PostUserCurrent2FaByProviderNameResponses, PostUserCurrentAvatarData, PostUserCurrentAvatarErrors, PostUserCurrentAvatarResponses, PostUserCurrentChangePasswordData, PostUserCurrentChangePasswordErrors, PostUserCurrentChangePasswordResponses, PostUserData, PostUserDataData, PostUserDataErrors, PostUserDataResponses, PostUserDisableData, PostUserDisableErrors, PostUserDisableResponses, PostUserEnableData, PostUserEnableErrors, PostUserEnableResponses, PostUserErrors, PostUserGroupByIdUsersData, PostUserGroupByIdUsersErrors, PostUserGroupByIdUsersResponses, PostUserGroupData, PostUserGroupErrors, PostUserGroupResponses, PostUserInviteCreatePasswordData, PostUserInviteCreatePasswordErrors, PostUserInviteCreatePasswordResponses, PostUserInviteData, PostUserInviteErrors, PostUserInviteResendData, PostUserInviteResendErrors, PostUserInviteResendResponses, PostUserInviteResponses, PostUserInviteVerifyData, PostUserInviteVerifyErrors, PostUserInviteVerifyResponses, PostUserResponses, PostUserSetUserGroupsData, PostUserSetUserGroupsErrors, PostUserSetUserGroupsResponses, PostUserUnlockData, PostUserUnlockErrors, PostUserUnlockResponses, PostWebhookData, PostWebhookErrors, PostWebhookResponses, PutDataTypeByIdData, PutDataTypeByIdErrors, PutDataTypeByIdMoveData, PutDataTypeByIdMoveErrors, PutDataTypeByIdMoveResponses, PutDataTypeByIdResponses, PutDataTypeFolderByIdData, PutDataTypeFolderByIdErrors, PutDataTypeFolderByIdResponses, PutDictionaryByIdData, PutDictionaryByIdErrors, PutDictionaryByIdMoveData, PutDictionaryByIdMoveErrors, PutDictionaryByIdMoveResponses, PutDictionaryByIdResponses, PutDocumentBlueprintByIdData, PutDocumentBlueprintByIdErrors, PutDocumentBlueprintByIdMoveData, PutDocumentBlueprintByIdMoveErrors, PutDocumentBlueprintByIdMoveResponses, PutDocumentBlueprintByIdResponses, PutDocumentBlueprintFolderByIdData, PutDocumentBlueprintFolderByIdErrors, PutDocumentBlueprintFolderByIdResponses, PutDocumentByIdData, PutDocumentByIdDomainsData, PutDocumentByIdDomainsErrors, PutDocumentByIdDomainsResponses, PutDocumentByIdErrors, PutDocumentByIdMoveData, PutDocumentByIdMoveErrors, PutDocumentByIdMoveResponses, PutDocumentByIdMoveToRecycleBinData, PutDocumentByIdMoveToRecycleBinErrors, PutDocumentByIdMoveToRecycleBinResponses, PutDocumentByIdNotificationsData, PutDocumentByIdNotificationsErrors, PutDocumentByIdNotificationsResponses, PutDocumentByIdPublicAccessData, PutDocumentByIdPublicAccessErrors, PutDocumentByIdPublicAccessResponses, PutDocumentByIdPublishData, PutDocumentByIdPublishErrors, PutDocumentByIdPublishResponses, PutDocumentByIdPublishWithDescendantsData, PutDocumentByIdPublishWithDescendantsErrors, PutDocumentByIdPublishWithDescendantsResponses, PutDocumentByIdResponses, PutDocumentByIdUnpublishData, PutDocumentByIdUnpublishErrors, PutDocumentByIdUnpublishResponses, PutDocumentSortData, PutDocumentSortErrors, PutDocumentSortResponses, PutDocumentTypeByIdData, PutDocumentTypeByIdErrors, PutDocumentTypeByIdImportData, PutDocumentTypeByIdImportErrors, PutDocumentTypeByIdImportResponses, PutDocumentTypeByIdMoveData, PutDocumentTypeByIdMoveErrors, PutDocumentTypeByIdMoveResponses, PutDocumentTypeByIdResponses, PutDocumentTypeFolderByIdData, PutDocumentTypeFolderByIdErrors, PutDocumentTypeFolderByIdResponses, PutDocumentVersionByIdPreventCleanupData, PutDocumentVersionByIdPreventCleanupErrors, PutDocumentVersionByIdPreventCleanupResponses, PutLanguageByIsoCodeData, PutLanguageByIsoCodeErrors, PutLanguageByIsoCodeResponses, PutMediaByIdData, PutMediaByIdErrors, PutMediaByIdMoveData, PutMediaByIdMoveErrors, PutMediaByIdMoveResponses, PutMediaByIdMoveToRecycleBinData, PutMediaByIdMoveToRecycleBinErrors, PutMediaByIdMoveToRecycleBinResponses, PutMediaByIdResponses, PutMediaByIdValidateData, PutMediaByIdValidateErrors, PutMediaByIdValidateResponses, PutMediaSortData, PutMediaSortErrors, PutMediaSortResponses, PutMediaTypeByIdData, PutMediaTypeByIdErrors, PutMediaTypeByIdImportData, PutMediaTypeByIdImportErrors, PutMediaTypeByIdImportResponses, PutMediaTypeByIdMoveData, PutMediaTypeByIdMoveErrors, PutMediaTypeByIdMoveResponses, PutMediaTypeByIdResponses, PutMediaTypeFolderByIdData, PutMediaTypeFolderByIdErrors, PutMediaTypeFolderByIdResponses, PutMemberByIdData, PutMemberByIdErrors, PutMemberByIdResponses, PutMemberByIdValidateData, PutMemberByIdValidateErrors, PutMemberByIdValidateResponses, PutMemberGroupByIdData, PutMemberGroupByIdErrors, PutMemberGroupByIdResponses, PutMemberTypeByIdData, PutMemberTypeByIdErrors, PutMemberTypeByIdImportData, PutMemberTypeByIdImportErrors, PutMemberTypeByIdImportResponses, PutMemberTypeByIdMoveData, PutMemberTypeByIdMoveErrors, PutMemberTypeByIdMoveResponses, PutMemberTypeByIdResponses, PutMemberTypeFolderByIdData, PutMemberTypeFolderByIdErrors, PutMemberTypeFolderByIdResponses, PutPackageCreatedByIdData, PutPackageCreatedByIdErrors, PutPackageCreatedByIdResponses, PutPartialViewByPathData, PutPartialViewByPathErrors, PutPartialViewByPathRenameData, PutPartialViewByPathRenameErrors, PutPartialViewByPathRenameResponses, PutPartialViewByPathResponses, PutProfilingStatusData, PutProfilingStatusErrors, PutProfilingStatusResponses, PutRecycleBinDocumentByIdRestoreData, PutRecycleBinDocumentByIdRestoreErrors, PutRecycleBinDocumentByIdRestoreResponses, PutRecycleBinMediaByIdRestoreData, PutRecycleBinMediaByIdRestoreErrors, PutRecycleBinMediaByIdRestoreResponses, PutScriptByPathData, PutScriptByPathErrors, PutScriptByPathRenameData, PutScriptByPathRenameErrors, PutScriptByPathRenameResponses, PutScriptByPathResponses, PutStylesheetByPathData, PutStylesheetByPathErrors, PutStylesheetByPathRenameData, PutStylesheetByPathRenameErrors, PutStylesheetByPathRenameResponses, PutStylesheetByPathResponses, PutTemplateByIdData, PutTemplateByIdErrors, PutTemplateByIdResponses, PutUmbracoManagementApiV11DocumentByIdValidate11Data, PutUmbracoManagementApiV11DocumentByIdValidate11Errors, PutUmbracoManagementApiV11DocumentByIdValidate11Responses, PutUserByIdData, PutUserByIdErrors, PutUserByIdResponses, PutUserDataData, PutUserDataErrors, PutUserDataResponses, PutUserGroupByIdData, PutUserGroupByIdErrors, PutUserGroupByIdResponses, PutWebhookByIdData, PutWebhookByIdErrors, PutWebhookByIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -2036,6 +2036,26 @@ export class DocumentService { }); } + /** + * Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec + */ + public static patchDocumentByIdPatch(options: Options) { + return (options.client ?? client).patch({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/umbraco/management/api/v1/document/{id}/patch', + ...options, + headers: { + 'Content-Type': 'application/json-patch+json', + ...options.headers + } + }); + } + /** * Gets the preview URL for a document. * diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index e113d73cb298..cbcd46e296da 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -1557,7 +1557,8 @@ export type MemberItemResponseModel = { export enum MemberKindModel { DEFAULT = 'Default', - API = 'Api' + API = 'Api', + EXTERNAL_ONLY = 'ExternalOnly' } export type MemberReferenceResponseModel = { @@ -1584,6 +1585,7 @@ export type MemberResponseModel = { lastPasswordChangeDate?: string | null; groups: Array; kind: MemberKindModel; + profileData?: string | null; }; export type MemberTypeCompositionModel = { @@ -2199,6 +2201,16 @@ export type PasswordConfigurationResponseModel = { requireUppercase: boolean; }; +export type PatchDocumentRequestModel = { + operations: Array; +}; + +export type PatchOperationRequestModel = { + op: string; + path: string; + value?: unknown; +}; + export type ProblemDetails = { type?: string | null; title?: string | null; @@ -6815,6 +6827,47 @@ export type PutDocumentByIdNotificationsResponses = { 200: unknown; }; +export type PatchDocumentByIdPatchData = { + body?: PatchDocumentRequestModel; + path: { + id: string; + }; + query?: never; + url: '/umbraco/management/api/v1/document/{id}/patch'; +}; + +export type PatchDocumentByIdPatchErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; + /** + * The resource is protected and requires an authentication token + */ + 401: unknown; + /** + * The authenticated user does not have access to this resource + */ + 403: unknown; + /** + * Not Found + */ + 404: ProblemDetails; + /** + * Unprocessable Content + */ + 422: ProblemDetails; +}; + +export type PatchDocumentByIdPatchError = PatchDocumentByIdPatchErrors[keyof PatchDocumentByIdPatchErrors]; + +export type PatchDocumentByIdPatchResponses = { + /** + * OK + */ + 200: unknown; +}; + export type GetDocumentByIdPreviewUrlData = { body?: never; path: { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.server.data-source.ts index ccbccf10829a..42ad8ad48b44 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.server.data-source.ts @@ -65,6 +65,7 @@ export class UmbMemberCollectionServerDataSource implements UmbCollectionDataSou lastLoginDate: item.lastLoginDate || null, lastLockoutDate: item.lastLockoutDate || null, lastPasswordChangeDate: item.lastPasswordChangeDate || null, + profileData: item.profileData ?? null, failedPasswordAttempts: item.failedPasswordAttempts, isApproved: item.isApproved, isLockedOut: item.isLockedOut, diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/views/table/member-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/views/table/member-table-collection-view.element.ts index 2fcf50c84353..b35233fa2fad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/views/table/member-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/views/table/member-table-collection-view.element.ts @@ -114,13 +114,15 @@ export class UmbMemberTableCollectionViewElement extends UmbLitElement { const kind = member.kind === UmbMemberKind.API ? this.localize.term('member_memberKindApi') - : this.localize.term('member_memberKindDefault'); + : member.kind === UmbMemberKind.EXTERNAL_ONLY + ? this.localize.term('member_memberKindExternalOnly') + : this.localize.term('member_memberKindDefault'); const memberType = memberTypes?.find((type) => type.unique === member.memberType.unique); return { id: member.unique, - icon: memberType?.icon, + icon: memberType?.icon || 'icon-user', data: [ { columnAlias: 'memberName', diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts index f2bba35ef91d..109217ea34f1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/components/member-picker-modal/member-picker-modal.element.ts @@ -127,7 +127,7 @@ export class UmbMemberPickerModalElement extends UmbModalBaseElement< @selected=${() => this.#pickerContext.selection.select(item.unique)} @deselected=${() => this.#pickerContext.selection.deselect(item.unique)} ?selected=${this.#pickerContext.selection.isSelected(item.unique)}> - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/item/member-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/item/member-item-ref.element.ts index 668838998700..5858f7189d78 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/item/member-item-ref.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/item/member-item-ref.element.ts @@ -79,8 +79,7 @@ export class UmbMemberItemRefElement extends UmbLitElement { } #renderIcon(item: UmbMemberItemModel) { - if (!item.memberType.icon) return; - return html``; + return html``; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/manifests.ts index cc14b9eb9177..2e20eb1f4dc8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/manifests.ts @@ -4,6 +4,7 @@ import { manifests as itemManifests } from './item/manifests.js'; import { manifests as memberPickerModalManifests } from './components/member-picker-modal/manifests.js'; import { manifests as menuItemManifests } from './menu-item/manifests.js'; import { manifests as pickerManifests } from './picker/manifests.js'; +import { manifests as profileDataManifests } from './profile-data/manifests.js'; import { manifests as propertyEditorManifests } from './property-editor/manifests.js'; import { manifests as referenceManifests } from './reference/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; @@ -20,6 +21,7 @@ export const manifests: Array = ...memberPickerModalManifests, ...menuItemManifests, ...pickerManifests, + ...profileDataManifests, ...propertyEditorManifests, ...referenceManifests, ...repositoryManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/info-app/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/info-app/manifests.ts new file mode 100644 index 000000000000..b0a6a7957c15 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/info-app/manifests.ts @@ -0,0 +1,26 @@ +import { UMB_MEMBER_WORKSPACE_ALIAS } from '../../workspace/constants.js'; +import { + UMB_WORKSPACE_CONDITION_ALIAS, + UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS, +} from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspaceInfoApp', + name: 'Member Profile Data Workspace Info App', + alias: 'Umb.WorkspaceInfoApp.Member.ProfileData', + element: () => import('./member-profile-data-workspace-info-app.element.js'), + // Higher weight surfaces the box above "Referenced by" (which has no explicit weight). + weight: 100, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_MEMBER_WORKSPACE_ALIAS, + }, + { + alias: UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS, + match: false, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/info-app/member-profile-data-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/info-app/member-profile-data-workspace-info-app.element.ts new file mode 100644 index 000000000000..7851e5958370 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/info-app/member-profile-data-workspace-info-app.element.ts @@ -0,0 +1,182 @@ +import { UMB_MEMBER_WORKSPACE_CONTEXT } from '../../workspace/member/member-workspace.context-token.js'; +import { css, customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-member-profile-data-workspace-info-app') +export class UmbMemberProfileDataWorkspaceInfoAppElement extends UmbLitElement { + @state() + private _profileData?: string | null; + + constructor() { + super(); + + this.consumeContext(UMB_MEMBER_WORKSPACE_CONTEXT, (context) => { + this.observe(context?.profileData, (data) => (this._profileData = data)); + }); + } + + #formatKey(key: string): string { + // Convert camelCase / PascalCase / snake_case / kebab-case into "Title Case". + // Common IdP claim shapes: givenName, family_name, preferred-username, Email, etc. + return key + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + #renderValue(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) { + return html`${this.localize.term('general_none')}`; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (Array.isArray(value)) { + // Arrays of primitives read well as a comma-separated list; + // anything richer falls back to JSON so the structure stays visible. + const allPrimitive = value.every( + (v) => + v === null || + v === undefined || + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean', + ); + if (allPrimitive) { + return value.map((v) => (v === null || v === undefined ? '—' : String(v))).join(', '); + } + return html`
${JSON.stringify(value, null, 2)}
`; + } + // Nested object — recurse one level, then fall back to JSON for anything deeper + // so the panel stays readable on pathological input. + if (depth >= 1) { + return html`
${JSON.stringify(value, null, 2)}
`; + } + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return html`${this.localize.term('general_none')}`; + } + return html` +
+ ${entries.map( + ([key, childValue]) => html` +
+ ${this.#formatKey(key)} + ${this.#renderValue(childValue, depth + 1)} +
+ `, + )} +
+ `; + } + + #renderContent() { + if (!this._profileData) return nothing; + + let parsed: unknown; + try { + parsed = JSON.parse(this._profileData); + } catch { + // Not valid JSON — fall back to the raw string. + return html`
${this._profileData}
`; + } + + // Top-level object: render each property as a label/value row — label left, value right — + // matching the existing Username / Email / Member Group layout via . + // Anything else (primitive, array, top-level array) falls back to pretty-printed JSON. + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + const entries = Object.entries(parsed as Record); + if (entries.length === 0) return nothing; + return html` + ${entries.map( + ([key, value]) => html` + +
${this.#renderValue(value)}
+
+ `, + )} + `; + } + + return html`
${JSON.stringify(parsed, null, 2)}
`; + } + + override render() { + // Only render the box for members that actually have profile data (i.e. external members + // whose integrator populated it). Content members and external members without a profile + // payload skip the box entirely. + if (!this._profileData) return nothing; + + return html` + + ${this.#renderContent()} + + `; + } + + static override styles = [ + css` + /* + * umb-workspace-info-app-layout zeroes --uui-box-default-padding so sibling apps can + * paint edge-to-edge. Reintroduce the inset on our rows so label + value align with + * the Username / Email / Member Group box above. + */ + umb-property-layout { + padding: var(--uui-size-space-4) var(--uui-size-layout-1); + } + umb-property-layout:first-of-type { + padding-top: var(--uui-size-space-5); + } + umb-property-layout:last-of-type { + padding-bottom: var(--uui-size-space-5); + } + + pre { + margin: var(--uui-size-space-3) var(--uui-size-layout-1); + padding: var(--uui-size-space-3) var(--uui-size-space-5); + background: var(--uui-color-surface-alt); + border-radius: var(--uui-border-radius, 3px); + white-space: pre-wrap; + word-break: break-word; + font-family: var(--uui-font-family-mono, monospace); + font-size: var(--uui-type-small-size, 0.75rem); + } + + .nested { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-2); + padding-left: var(--uui-size-space-4); + border-left: 2px solid var(--uui-color-divider); + } + .nested-entry { + display: flex; + gap: var(--uui-size-space-4); + align-items: baseline; + } + .nested-key { + min-width: 120px; + flex-shrink: 0; + font-weight: 600; + color: var(--uui-color-text-alt); + } + .nested-value { + word-break: break-word; + } + /* Pretty-printed fallback inside a nested slot shouldn't carry the top-level box inset. */ + .nested-value pre { + margin: 0; + } + `, + ]; +} + +export default UmbMemberProfileDataWorkspaceInfoAppElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-member-profile-data-workspace-info-app': UmbMemberProfileDataWorkspaceInfoAppElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/manifests.ts new file mode 100644 index 000000000000..c72138184b5a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/profile-data/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as infoAppManifests } from './info-app/manifests.js'; + +export const manifests: Array = [...infoAppManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/detail/member-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/detail/member-detail.server.data-source.ts index c124db8746f1..a0653b485c9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/detail/member-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/detail/member-detail.server.data-source.ts @@ -49,6 +49,7 @@ export class UmbMemberServerDataSource extends UmbControllerBase implements UmbD lastLoginDate: null, lastLockoutDate: null, lastPasswordChangeDate: null, + profileData: null, groups: [], values: [], flags: [], @@ -102,6 +103,7 @@ export class UmbMemberServerDataSource extends UmbControllerBase implements UmbD lastLoginDate: data.lastLoginDate || null, lastLockoutDate: data.lastLockoutDate || null, lastPasswordChangeDate: data.lastPasswordChangeDate || null, + profileData: data.profileData ?? null, groups: data.groups, values: data.values.map((value) => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/types.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/types.ts index cb8c9c153dcc..6dbbb80c0719 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/types.ts @@ -25,6 +25,7 @@ export interface UmbMemberDetailModel extends UmbContentDetailModel { }; newPassword?: string; oldPassword?: string; + profileData: string | null; unique: string; username: string; values: Array; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/utils/index.ts index 9b691b0035f2..343d5ec1faf3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/utils/index.ts @@ -1,6 +1,7 @@ -export type UmbMemberKindType = 'Default' | 'Api'; +export type UmbMemberKindType = 'Default' | 'Api' | 'ExternalOnly'; export const UmbMemberKind = Object.freeze({ DEFAULT: 'Default', API: 'Api', + EXTERNAL_ONLY: 'ExternalOnly', }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-editor.element.ts index 8b487213ff63..83472ef2120f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-editor.element.ts @@ -1,4 +1,5 @@ import type { UmbMemberVariantOptionModel } from '../../types.js'; +import { UmbMemberKind } from '../../utils/index.js'; import { UMB_MEMBER_WORKSPACE_CONTEXT } from './member-workspace.context-token.js'; import { UmbMemberWorkspaceSplitViewElement } from './member-workspace-split-view.element.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @@ -22,6 +23,9 @@ export class UmbMemberWorkspaceEditorElement extends UmbLitElement { @state() private _routes?: Array; + @state() + private _isExternalOnly = false; + constructor() { super(); @@ -29,6 +33,13 @@ export class UmbMemberWorkspaceEditorElement extends UmbLitElement { this.#workspaceContext = instance; this.#observeVariants(); this.#observeForbidden(); + this.observe(this.#workspaceContext?.kind, (kind) => { + this._isExternalOnly = kind === UmbMemberKind.EXTERNAL_ONLY; + if (this._isExternalOnly) { + // External members have no content type variants — generate a single invariant route. + this.#generateRoutes([{ unique: 'invariant', culture: null, segment: null } as UmbMemberVariantOptionModel]); + } + }, '_observeKind'); }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-split-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-split-view.element.ts index 0fce5fc05a94..e26487513d6d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-split-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace-split-view.element.ts @@ -1,4 +1,5 @@ import { UMB_MEMBER_ROOT_WORKSPACE_PATH } from '../../paths.js'; +import { UmbMemberKind } from '../../utils/index.js'; import { UMB_MEMBER_WORKSPACE_CONTEXT } from './member-workspace.context-token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, nothing, customElement, state, repeat, ifDefined } from '@umbraco-cms/backoffice/external/lit'; @@ -16,6 +17,9 @@ export class UmbMemberWorkspaceSplitViewElement extends UmbLitElement { @state() private _icon?: string; + @state() + private _isExternalOnly = false; + constructor() { super(); @@ -24,6 +28,9 @@ export class UmbMemberWorkspaceSplitViewElement extends UmbLitElement { this._workspaceContext = context; this.#observeActiveVariantInfo(); this.#observeIcon(); + this.observe(this._workspaceContext?.kind, (kind) => { + this._isExternalOnly = kind === UmbMemberKind.EXTERNAL_ONLY; + }, '_observeKind'); }); } @@ -63,7 +70,7 @@ export class UmbMemberWorkspaceSplitViewElement extends UmbLitElement { )} - ` + ${this._isExternalOnly ? nothing : html``}` : nothing; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts index 1a7ea490da43..4bf088ddbbb6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts @@ -2,6 +2,7 @@ import { UmbMemberValidationRepository, type UmbMemberDetailRepository } from '. import type { UmbMemberDetailModel, UmbMemberVariantModel } from '../../types.js'; import { UmbMemberPropertyDatasetContext } from '../../property-dataset-context/member-property-dataset.context.js'; import { UMB_MEMBER_ENTITY_TYPE, UMB_MEMBER_ROOT_ENTITY_TYPE } from '../../entity.js'; +import { UmbMemberKind } from '../../utils/index.js'; import { UMB_MEMBER_DETAIL_REPOSITORY_ALIAS } from '../../repository/detail/manifests.js'; import { UMB_CREATE_MEMBER_WORKSPACE_PATH_PATTERN, UMB_EDIT_MEMBER_WORKSPACE_PATH_PATTERN } from '../../paths.js'; import { @@ -45,6 +46,7 @@ export class UmbMemberWorkspaceContext #entityContentTypeContext = new UmbEntityContentTypeEntityContext(this); readonly createDate = this._data.createObservablePartOfCurrent((data) => data?.variants[0].createDate); readonly updateDate = this._data.createObservablePartOfCurrent((data) => data?.variants[0].updateDate); + readonly profileData = this._data.createObservablePartOfCurrent((data) => data?.profileData); constructor(host: UmbControllerHost) { super(host, { @@ -60,9 +62,11 @@ export class UmbMemberWorkspaceContext this.observe( this.contentTypeUnique, (unique) => { - this.#entityContentTypeContext.setEntityType(unique ? UMB_MEMBER_TYPE_ENTITY_TYPE : undefined); - this.#entityContentTypeContext.setUnique(unique ?? undefined); - if (unique) { + // External-only members have no content type (empty Guid). + const hasContentType = unique && unique !== '00000000-0000-0000-0000-000000000000'; + this.#entityContentTypeContext.setEntityType(hasContentType ? UMB_MEMBER_TYPE_ENTITY_TYPE : undefined); + this.#entityContentTypeContext.setUnique(hasContentType ? unique : undefined); + if (hasContentType) { this.structure.loadType(unique); } }, @@ -188,6 +192,26 @@ export class UmbMemberWorkspaceContext }); } + protected override async _processIncomingData(data: ContentModel): Promise { + // External-only members have no content type — skip the base class's + // content type loading which would 404 on the empty Guid. + if (data.kind === UmbMemberKind.EXTERNAL_ONLY) { + return data; + } + + return super._processIncomingData(data); + } + + override async submit() { + // External-only members cannot be saved through the backoffice. + const data = this.getData(); + if (data?.kind === UmbMemberKind.EXTERNAL_ONLY) { + throw new Error('External-only members cannot be modified through the backoffice.'); + } + + return super.submit(); + } + /** * Gets the unique identifier of the content type. * @deprecated Use `getContentTypeUnique` instead. diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts index 8998552fd146..f6918cd29cfe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member-info.element.ts @@ -2,7 +2,7 @@ import { UMB_MEMBER_WORKSPACE_CONTEXT } from '../../member-workspace.context-tok import { UmbMemberKind, type UmbMemberKindType } from '../../../../utils/index.js'; import { TimeFormatOptions } from './utils.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { css, html, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; @@ -96,6 +96,10 @@ export class UmbMemberWorkspaceViewMemberInfoElement extends UmbLitElement imple return this.#renderGeneralSection(); } + #isExternalOnly() { + return this._memberKind === UmbMemberKind.EXTERNAL_ONLY; + } + #renderGeneralSection() { return html` @@ -107,24 +111,28 @@ export class UmbMemberWorkspaceViewMemberInfoElement extends UmbLitElement imple

Last edited

${this._updateDate} -
-

Member Type

- - - -
+ ${this.#isExternalOnly() + ? nothing + : html`
+

Member Type

+ + + +
`}

${this._memberKind === UmbMemberKind.API ? this.localize.term('member_memberKindApi') - : this.localize.term('member_memberKindDefault')}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts index fdffe76e173f..ab77f37e0108 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts @@ -1,8 +1,9 @@ import { UMB_MEMBER_WORKSPACE_CONTEXT } from '../../member-workspace.context-token.js'; import type { UmbMemberDetailModel } from '../../../../types.js'; +import { UmbMemberKind } from '../../../../utils/index.js'; import { TimeFormatOptions } from './utils.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { css, html, customElement, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, when, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; import type { UUIBooleanInputEvent } from '@umbraco-cms/backoffice/external/uui'; @@ -25,6 +26,10 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement this.observe(this._workspaceContext?.isNew, (isNew) => { this._isNew = !!isNew; }); + + this.observe(this._workspaceContext?.kind, (kind) => { + this._isExternalOnly = kind === UmbMemberKind.EXTERNAL_ONLY; + }); }); this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => { @@ -46,6 +51,9 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement @state() private _hasAccessToSensitiveData = false; + @state() + private _isExternalOnly = false; + #onChange(propertyName: keyof UmbMemberDetailModel, value: UmbMemberDetailModel[keyof UmbMemberDetailModel]) { if (!this._workspaceContext) return; @@ -165,53 +173,61 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement return html`
+ ${this._isExternalOnly ? this.#renderExternalMemberBanner() : nothing} - + this.#onChange('username', (e.target as HTMLInputElement).value)}> - + this.#onChange('email', (e.target as HTMLInputElement).value)}> - ${this.#renderPasswordInput()} + ${this._isExternalOnly ? nothing : this.#renderPasswordInput()} ${when( - this._hasAccessToSensitiveData, + this._hasAccessToSensitiveData && !this._isExternalOnly, () => html` `, )} - - + this.#onChange('isTwoFactorEnabled', e.target.checked)}> - - + ?disabled=${this._isNew || !this._workspaceContext.isTwoFactorEnabled} + .checked=${this._workspaceContext.isTwoFactorEnabled} + @change=${(e: UUIBooleanInputEvent) => this.#onChange('isTwoFactorEnabled', e.target.checked)}> + + + `}
@@ -254,6 +274,20 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement `; } + #renderExternalMemberBanner() { + return html` + +
+ +
+ ${this.localize.term('member_externalMemberTitle')} +

${this.localize.term('member_externalMemberDescription')}

+
+
+
+ `; + } + #renderRightColumn() { if (!this._workspaceContext) return; @@ -261,10 +295,12 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement
-
-

Failed login attempts

- ${this._workspaceContext.failedPasswordAttempts} -
+ ${this._isExternalOnly + ? nothing + : html`
+

Failed login attempts

+ ${this._workspaceContext.failedPasswordAttempts} +
`}

Last lockout date

@@ -281,14 +317,16 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement : this.localize.term('general_never')}
-
-

Password changed

- - ${this._workspaceContext.lastPasswordChangeDate - ? this.localize.date(this._workspaceContext.lastPasswordChangeDate, TimeFormatOptions) - : this.localize.term('general_never')} - -
+ ${this._isExternalOnly + ? nothing + : html`
+

Password changed

+ + ${this._workspaceContext.lastPasswordChangeDate + ? this.localize.date(this._workspaceContext.lastPasswordChangeDate, TimeFormatOptions) + : this.localize.term('general_never')} + +
`}
@@ -331,6 +369,13 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement flex-direction: column; gap: var(--uui-size-space-4); } + /* Space sibling workspace info apps (e.g. "Profile data", "Referenced by") + in the same way the surrounding left-column boxes are spaced. */ + div.container { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-4); + } uui-box { height: fit-content; } @@ -347,6 +392,21 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement margin-top: 0; color: var(--uui-color-danger); } + #external-member-banner { + display: flex; + align-items: flex-start; + gap: var(--uui-size-space-4); + } + #external-member-banner uui-icon { + font-size: 1.5em; + color: var(--uui-color-warning); + flex-shrink: 0; + margin-top: var(--uui-size-space-1); + } + #external-member-banner p { + margin: var(--uui-size-space-2) 0 0 0; + color: var(--uui-color-text-alt); + } h4 { margin: 0; diff --git a/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs b/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs index 8aeab7afc665..a80037c64d49 100644 --- a/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs +++ b/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs @@ -72,6 +72,13 @@ public ProfileModelBuilder WithCustomProperties(bool lookupProperties) Key = member.Key, }; + // External-only members have no content type or content properties. + // Return the model populated from identity fields only. + if (member.IsExternalOnly) + { + return model; + } + IMemberType? memberType = member.MemberTypeAlias is null ? null : MemberTypeService.Get(member.MemberTypeAlias); if (memberType == null) { diff --git a/tests/Umbraco.Tests.Common/Builders/ExternalMemberIdentityBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ExternalMemberIdentityBuilder.cs new file mode 100644 index 000000000000..a92366391e33 --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/ExternalMemberIdentityBuilder.cs @@ -0,0 +1,84 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Tests.Common.Builders; + +public class ExternalMemberIdentityBuilder +{ + private string _email = "test@example.com"; + private string _userName = "test@example.com"; + private string? _name = "Test Member"; + private bool _isApproved = true; + private bool _isLockedOut; + private Guid? _key; + private string? _profileData; + + public ExternalMemberIdentityBuilder WithEmail(string email) + { + _email = email; + return this; + } + + public ExternalMemberIdentityBuilder WithUserName(string userName) + { + _userName = userName; + return this; + } + + public ExternalMemberIdentityBuilder WithName(string? name) + { + _name = name; + return this; + } + + public ExternalMemberIdentityBuilder WithIsApproved(bool isApproved) + { + _isApproved = isApproved; + return this; + } + + public ExternalMemberIdentityBuilder WithIsLockedOut(bool isLockedOut) + { + _isLockedOut = isLockedOut; + return this; + } + + public ExternalMemberIdentityBuilder WithKey(Guid key) + { + _key = key; + return this; + } + + public ExternalMemberIdentityBuilder WithProfileData(string? profileData) + { + _profileData = profileData; + return this; + } + + public ExternalMemberIdentity Build() + { + DateTime now = DateTime.UtcNow; + return new() + { + Key = _key ?? Guid.NewGuid(), + Email = _email, + UserName = _userName, + Name = _name, + IsApproved = _isApproved, + IsLockedOut = _isLockedOut, + CreateDate = now, + UpdateDate = now, + SecurityStamp = Guid.NewGuid().ToString(), + ProfileData = _profileData, + }; + } + + public static ExternalMemberIdentity CreateSimple(string email = "test@example.com", string? name = null) => + new ExternalMemberIdentityBuilder() + .WithEmail(email) + .WithUserName(email) + .WithName(name ?? email) + .Build(); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ExternalMemberServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ExternalMemberServiceTests.cs new file mode 100644 index 000000000000..74ec8730bedf --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ExternalMemberServiceTests.cs @@ -0,0 +1,366 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest +{ + private IExternalMemberService ExternalMemberService => GetRequiredService(); + + private IMemberTypeService MemberTypeService => GetRequiredService(); + + private IMemberService MemberService => GetRequiredService(); + + [Test] + public async Task Can_Create_And_Get_External_Member() + { + // Arrange + var identity = new ExternalMemberIdentityBuilder() + .WithEmail("create-test@example.com") + .WithUserName("create-test@example.com") + .WithName("Create Test") + .WithIsApproved(true) + .Build(); + + // Act + var result = await ExternalMemberService.CreateAsync(identity); + + // Assert + Assert.IsTrue(result.Success); + Assert.AreEqual(ExternalMemberOperationStatus.Success, result.Status); + + var retrieved = await ExternalMemberService.GetByKeyAsync(result.Result.Key); + Assert.IsNotNull(retrieved); + Assert.AreEqual(result.Result.Key, retrieved!.Key); + Assert.AreEqual("create-test@example.com", retrieved.Email); + Assert.AreEqual("create-test@example.com", retrieved.UserName); + Assert.AreEqual("Create Test", retrieved.Name); + Assert.IsTrue(retrieved.IsApproved); + Assert.IsFalse(retrieved.IsLockedOut); + } + + [Test] + public async Task Can_Get_By_Email() + { + // Arrange + var identity = ExternalMemberIdentityBuilder.CreateSimple("email-test@example.com", "Email Test"); + await ExternalMemberService.CreateAsync(identity); + + // Act + var retrieved = await ExternalMemberService.GetByEmailAsync("email-test@example.com"); + + // Assert + Assert.IsNotNull(retrieved); + Assert.AreEqual("email-test@example.com", retrieved!.Email); + Assert.AreEqual("Email Test", retrieved.Name); + } + + [Test] + public async Task Can_Get_By_Username() + { + // Arrange + var identity = new ExternalMemberIdentityBuilder() + .WithEmail("username-test@example.com") + .WithUserName("username-lookup") + .WithName("Username Test") + .Build(); + await ExternalMemberService.CreateAsync(identity); + + // Act + var retrieved = await ExternalMemberService.GetByUsernameAsync("username-lookup"); + + // Assert + Assert.IsNotNull(retrieved); + Assert.AreEqual("username-lookup", retrieved!.UserName); + Assert.AreEqual("username-test@example.com", retrieved.Email); + } + + [Test] + public async Task Can_Update_External_Member() + { + // Arrange + var identity = ExternalMemberIdentityBuilder.CreateSimple("update-test@example.com", "Before Update"); + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + + var member = await ExternalMemberService.GetByKeyAsync(createResult.Result.Key); + Assert.IsNotNull(member); + + // Act + member!.Name = "After Update"; + member.Email = "updated@example.com"; + member.IsApproved = false; + var updateResult = await ExternalMemberService.UpdateAsync(member); + + // Assert + Assert.IsTrue(updateResult.Success); + Assert.AreEqual(ExternalMemberOperationStatus.Success, updateResult.Status); + + var retrieved = await ExternalMemberService.GetByKeyAsync(createResult.Result.Key); + Assert.IsNotNull(retrieved); + Assert.AreEqual("After Update", retrieved!.Name); + Assert.AreEqual("updated@example.com", retrieved.Email); + Assert.IsFalse(retrieved.IsApproved); + } + + [Test] + public async Task Can_Delete_External_Member() + { + // Arrange + var identity = ExternalMemberIdentityBuilder.CreateSimple("delete-test@example.com", "Delete Test"); + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + + // Verify it exists first + var existing = await ExternalMemberService.GetByKeyAsync(createResult.Result.Key); + Assert.IsNotNull(existing); + + // Act + var deleteResult = await ExternalMemberService.DeleteAsync(createResult.Result.Key); + + // Assert + Assert.IsTrue(deleteResult.Success); + Assert.AreEqual(ExternalMemberOperationStatus.Success, deleteResult.Status); + + var retrieved = await ExternalMemberService.GetByKeyAsync(createResult.Result.Key); + Assert.IsNull(retrieved); + } + + [Test] + public async Task Delete_Returns_NotFound_For_NonExistent() + { + // Act + var result = await ExternalMemberService.DeleteAsync(Guid.NewGuid()); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(ExternalMemberOperationStatus.NotFound, result.Status); + } + + [Test] + public async Task Can_Assign_And_Get_Roles() + { + // Arrange + var identity = ExternalMemberIdentityBuilder.CreateSimple("roles-test@example.com", "Roles Test"); + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + + MemberService.AddRole("ExternalTestGroup"); + + // Act + var assignResult = await ExternalMemberService.AssignRolesAsync(createResult.Result.Key, ["ExternalTestGroup"]); + var roles = await ExternalMemberService.GetRolesAsync(createResult.Result.Key); + + // Assert + Assert.IsTrue(assignResult.Success); + Assert.IsNotNull(roles); + CollectionAssert.Contains(roles.ToList(), "ExternalTestGroup"); + } + + [Test] + public async Task Can_Remove_Roles() + { + // Arrange + var identity = ExternalMemberIdentityBuilder.CreateSimple("remove-roles@example.com", "Remove Roles Test"); + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + + MemberService.AddRole("RemovableGroup"); + await ExternalMemberService.AssignRolesAsync(createResult.Result.Key, ["RemovableGroup"]); + + // Verify role is assigned + var rolesBefore = await ExternalMemberService.GetRolesAsync(createResult.Result.Key); + CollectionAssert.Contains(rolesBefore.ToList(), "RemovableGroup"); + + // Act + var removeResult = await ExternalMemberService.RemoveRolesAsync(createResult.Result.Key, ["RemovableGroup"]); + + // Assert + Assert.IsTrue(removeResult.Success); + var rolesAfter = await ExternalMemberService.GetRolesAsync(createResult.Result.Key); + CollectionAssert.DoesNotContain(rolesAfter.ToList(), "RemovableGroup"); + } + + [Test] + public async Task Can_Store_And_Retrieve_ProfileData() + { + // Arrange + var profileJson = """{"firstName":"John","lastName":"Doe","age":30}"""; + var identity = new ExternalMemberIdentityBuilder() + .WithEmail("profile-test@example.com") + .WithUserName("profile-test@example.com") + .WithName("Profile Test") + .WithProfileData(profileJson) + .Build(); + + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + + // Act + var retrieved = await ExternalMemberService.GetByKeyAsync(createResult.Result.Key); + + // Assert + Assert.IsNotNull(retrieved); + Assert.AreEqual(profileJson, retrieved!.ProfileData); + } + + [Test] + public async Task GetByKey_Returns_Null_For_NonExistent() + { + // Act + var retrieved = await ExternalMemberService.GetByKeyAsync(Guid.NewGuid()); + + // Assert + Assert.IsNull(retrieved); + } + + [Test] + public async Task Cross_Store_Uniqueness_Rejects_Duplicate_Username() + { + // Arrange - create a content-based member first. + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + IMember member = MemberBuilder.CreateSimpleMember(memberType, "test", "content-member@test.com", "password", "shared-username"); + MemberService.Save(member); + + // Act - creating an external member with the same username should fail. + var externalIdentity = new ExternalMemberIdentityBuilder() + .WithEmail("external-unique@example.com") + .WithUserName("shared-username") + .WithName("Duplicate Username Test") + .Build(); + + var result = await ExternalMemberService.CreateAsync(externalIdentity); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(ExternalMemberOperationStatus.DuplicateUsername, result.Status); + } + + [Test] + public async Task Cross_Store_Uniqueness_Rejects_Duplicate_Email_When_Required() + { + // Arrange - create a content-based member first (note: MemberRequireUniqueEmail defaults to true). + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + IMember member = MemberBuilder.CreateSimpleMember(memberType, "test", "shared@test.com", "password", "content-user"); + MemberService.Save(member); + + // Act - creating an external member with the same email should fail. + var externalIdentity = new ExternalMemberIdentityBuilder() + .WithEmail("shared@test.com") + .WithUserName("external-unique-user") + .WithName("Duplicate Email Test") + .Build(); + + var result = await ExternalMemberService.CreateAsync(externalIdentity); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(ExternalMemberOperationStatus.DuplicateEmail, result.Status); + } + + [Test] + public async Task Can_Convert_External_To_Content_Member() + { + // Arrange — create external member with a group. + var identity = new ExternalMemberIdentityBuilder() + .WithEmail("convert-to-content@test.com") + .WithUserName("convert-to-content") + .WithName("Convert Test") + .Build(); + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + var originalKey = createResult.Result.Key; + + MemberService.AddRole("ConvertGroup"); + await ExternalMemberService.AssignRolesAsync(originalKey, ["ConvertGroup"]); + + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + + // Act + var result = await ExternalMemberService.ConvertToContentMemberAsync(originalKey, memberType.Alias); + + // Assert — content member created with same key and identity fields. + Assert.IsTrue(result.Success); + Assert.IsNotNull(result.Result); + Assert.AreEqual(originalKey, result.Result!.Key); + Assert.AreEqual("convert-to-content@test.com", result.Result.Email); + Assert.AreEqual("convert-to-content", result.Result.Username); + + // Assert — external member record removed. + var externalMember = await ExternalMemberService.GetByKeyAsync(originalKey); + Assert.IsNull(externalMember); + + // Assert — group memberships migrated. + IEnumerable contentRoles = MemberService.GetAllRoles(result.Result.Username); + CollectionAssert.Contains(contentRoles.ToList(), "ConvertGroup"); + } + + [Test] + public async Task Can_Convert_External_To_Content_Member_With_ProfileData_Callback() + { + // Arrange — create an external member with profile data. + var profileJson = """{"department":"Engineering","floor":3}"""; + var identity = new ExternalMemberIdentityBuilder() + .WithEmail("profile-promote@test.com") + .WithUserName("profile-promote") + .WithName("Profile Promote") + .WithProfileData(profileJson) + .Build(); + var createResult = await ExternalMemberService.CreateAsync(identity); + Assert.IsTrue(createResult.Success); + + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + + // Act — use the callback to map profileData into a content property. + string? capturedProfileData = null; + var result = await ExternalMemberService.ConvertToContentMemberAsync( + createResult.Result.Key, + memberType.Alias, + (member, profileData) => + { + capturedProfileData = profileData; + member.SetValue("title", "From Profile: Engineering"); + }); + + // Assert — callback received the profileData and property was persisted. + Assert.IsTrue(result.Success); + Assert.AreEqual(profileJson, capturedProfileData); + + IMember? reloaded = MemberService.GetById(result.Result!.Key); + Assert.IsNotNull(reloaded); + Assert.AreEqual("From Profile: Engineering", reloaded!.GetValue("title")); + + // Assert — without a callback, properties would remain empty. + // (Verified by the absence of any auto-mapped properties on the result.) + } + + [Test] + public async Task Convert_External_To_Content_Returns_NotFound_For_NonExistent() + { + // Act + var result = await ExternalMemberService.ConvertToContentMemberAsync(Guid.NewGuid(), "Member"); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(ExternalMemberOperationStatus.NotFound, result.Status); + } + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/MemberUserStoreConcurrencyTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/MemberUserStoreConcurrencyTests.cs new file mode 100644 index 000000000000..f6892d1a3a12 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/MemberUserStoreConcurrencyTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Identity; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Security; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + WithApplication = true)] +internal sealed class MemberUserStoreConcurrencyTests : UmbracoIntegrationTest +{ + private IMemberUserStore MemberUserStore => GetRequiredService(); + + /// + /// Simulates concurrent member auto-link registrations as would occur + /// when multiple users sign in via an external provider simultaneously. + /// Each auto-link performs CreateAsync followed by UpdateAsync (security stamp). + /// On SQLite, if read and write operations share a transaction scope, + /// concurrent requests contend for the write lock and fail with SQLITE_LOCKED. + /// + [Explicit] + [TestCase(true)] + [TestCase(false)] + public async Task Concurrent_Member_AutoLink_Should_Not_Cause_Database_Lock_Errors(bool externalOnly) + { + const int concurrentOperations = 5; + var exceptions = new ConcurrentBag(); + var barrier = new Barrier(concurrentOperations); + + var tasks = new List(); + for (var i = 0; i < concurrentOperations; i++) + { + var index = i; + using (ExecutionContext.SuppressFlow()) + { + tasks.Add(Task.Run(async () => + { + barrier.SignalAndWait(); + try + { + await SimulateMemberAutoLinkAsync(index, externalOnly); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + })); + } + } + + // Allow up to 30 seconds — if operations are blocked by SQLite locks, + // they'll either fail with SQLITE_LOCKED or still be waiting when time expires. + var completedInTime = await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(30)) + .ContinueWith(t => t.IsCompletedSuccessfully); + + // Build a combined failure message from lock errors and/or timed-out operations. + var failures = new List(exceptions.Select(e => e.Message)); + if (completedInTime is false) + { + var pendingCount = tasks.Count(t => !t.IsCompleted); + failures.Add($"{pendingCount} operation(s) still blocked on database lock after 30 seconds"); + } + + Assert.IsEmpty(failures, string.Join("\n", failures)); + } + + /// + /// Simulates the auto-link flow that MemberSignInManager performs: + /// 1. Create the member via UserStore.CreateAsync. + /// 2. Update the member via UserStore.UpdateAsync (as happens during sign-in for security stamp). + /// + private async Task SimulateMemberAutoLinkAsync(int index, bool externalOnly) + { + // Use a short cancellation timeout so blocked operations fail fast + // instead of waiting for the full SQLite busy timeout (~30s per operation). + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var email = $"concurrent-{index}@test.com"; + var user = MemberIdentityUser.CreateNew( + email, + email, + Constants.Conventions.MemberTypes.DefaultAlias, + isApproved: true, + name: $"Concurrent Test {index}"); + user.IsExternalOnly = externalOnly; + + // Step 1: Create (as MemberSignInManager.AutoLinkAndSignInExternalAccount does). + IdentityResult createResult = await MemberUserStore.CreateAsync(user, cts.Token); + if (createResult.Succeeded is false) + { + throw new InvalidOperationException( + $"CreateAsync failed for user {index}: {string.Join(", ", createResult.Errors.Select(e => e.Description))}"); + } + + // Step 2: Update security stamp (as SignInOrTwoFactorAsync does after sign-in). + user.SecurityStamp = Guid.NewGuid().ToString(); + IdentityResult updateResult = await MemberUserStore.UpdateAsync(user, cts.Token); + if (updateResult.Succeeded is false) + { + throw new InvalidOperationException( + $"UpdateAsync failed for user {index}: {string.Join(", ", updateResult.Errors.Select(e => e.Description))}"); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberEditingServiceTests.cs index 6f6b79e649cc..a9375082ba7a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberEditingServiceTests.cs @@ -27,6 +27,8 @@ internal sealed class MemberEditingServiceTests : UmbracoIntegrationTest private IMemberGroupService MemberGroupService => GetRequiredService(); + private IExternalMemberService ExternalMemberService => GetRequiredService(); + [Test] public async Task Can_Create_Member() { @@ -368,6 +370,130 @@ public async Task Sensitive_Properties_Are_Retained_When_Updating_Without_Access Assert.IsFalse(member.IsLockedOut); } + [Test] + public async Task IsExternalMember_Returns_True_For_External_Member() + { + // Arrange + var externalMember = new ExternalMemberIdentityBuilder() + .WithEmail("external@test.com") + .WithUserName("external@test.com") + .Build(); + await ExternalMemberService.CreateAsync(externalMember); + + // Act + var result = await MemberEditingService.IsExternalMemberAsync(externalMember.Key); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public async Task IsExternalMember_Returns_False_For_Content_Member() + { + // Arrange + var member = await CreateMemberAsync(); + + // Act + var result = await MemberEditingService.IsExternalMemberAsync(member.Key); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public async Task IsExternalMember_Returns_False_For_NonExistent_Key() + { + // Act + var result = await MemberEditingService.IsExternalMemberAsync(Guid.NewGuid()); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public async Task GetExternalMember_Returns_Member_For_External_Member() + { + // Arrange + var externalMember = new ExternalMemberIdentityBuilder() + .WithEmail("get-external@test.com") + .WithUserName("get-external@test.com") + .WithName("Get External Test") + .Build(); + await ExternalMemberService.CreateAsync(externalMember); + + // Act + var result = await MemberEditingService.GetExternalMemberAsync(externalMember.Key); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(externalMember.Key, result!.Key); + Assert.AreEqual("get-external@test.com", result.Email); + Assert.AreEqual("Get External Test", result.Name); + } + + [Test] + public async Task GetExternalMember_Returns_Null_For_Content_Member() + { + // Arrange + var member = await CreateMemberAsync(); + + // Act + var result = await MemberEditingService.GetExternalMemberAsync(member.Key); + + // Assert + Assert.IsNull(result); + } + + [Test] + public async Task GetExternalMember_Returns_Null_For_NonExistent_Key() + { + // Act + var result = await MemberEditingService.GetExternalMemberAsync(Guid.NewGuid()); + + // Assert + Assert.IsNull(result); + } + + [Test] + public async Task Can_Delete_External_Member() + { + // Arrange + var externalMember = new ExternalMemberIdentityBuilder() + .WithEmail("delete-external@test.com") + .WithUserName("delete-external@test.com") + .Build(); + await ExternalMemberService.CreateAsync(externalMember); + + // Verify it exists. + Assert.IsTrue(await MemberEditingService.IsExternalMemberAsync(externalMember.Key)); + + // Act + var result = await MemberEditingService.DeleteAsync(externalMember.Key, Constants.Security.SuperUserKey); + + // Assert + Assert.IsTrue(result.Success); + Assert.IsFalse(await MemberEditingService.IsExternalMemberAsync(externalMember.Key)); + } + + [Test] + public async Task Delete_External_Member_Does_Not_Affect_Content_Members() + { + // Arrange — create both a content member and an external member. + var contentMember = await CreateMemberAsync(); + var externalMember = new ExternalMemberIdentityBuilder() + .WithEmail("ext-only@test.com") + .WithUserName("ext-only@test.com") + .Build(); + await ExternalMemberService.CreateAsync(externalMember); + + // Act — delete the external member. + await MemberEditingService.DeleteAsync(externalMember.Key, Constants.Security.SuperUserKey); + + // Assert — content member still exists. + var retrievedContent = await MemberEditingService.GetAsync(contentMember.Key); + Assert.IsNotNull(retrievedContent); + } + private IUser SuperUser() => GetRequiredService().GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult(); private async Task CreateMemberAsync(Guid? key = null, bool titleIsSensitive = false) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberFilterServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberFilterServiceTests.cs new file mode 100644 index 000000000000..2b8962b16f78 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberFilterServiceTests.cs @@ -0,0 +1,298 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +internal sealed class MemberFilterServiceTests : UmbracoIntegrationTest +{ + private IMemberFilterService MemberFilterService => GetRequiredService(); + + private IExternalMemberService ExternalMemberService => GetRequiredService(); + + private IMemberService MemberService => GetRequiredService(); + + private IMemberTypeService MemberTypeService => GetRequiredService(); + + [Test] + public async Task Filter_Returns_Content_Members() + { + // Arrange + await CreateContentMemberAsync("content@test.com", "content-user"); + + // Act + PagedModel result = await MemberFilterService.FilterAsync(new MemberFilter()); + + // Assert + Assert.AreEqual(1, result.Total); + var item = result.Items.First(); + Assert.AreEqual("content@test.com", item.Email); + Assert.IsFalse(item.IsExternalOnly); + Assert.AreEqual(MemberKind.Default, item.Kind); + Assert.IsNotNull(item.MemberTypeKey); + Assert.AreNotEqual(Guid.Empty, item.MemberTypeKey); + Assert.IsNotNull(item.MemberTypeIcon); + } + + [Test] + public async Task Filter_Returns_External_Members() + { + // Arrange + await CreateExternalMemberAsync("external@test.com", "external-user"); + + // Act + PagedModel result = await MemberFilterService.FilterAsync(new MemberFilter()); + + // Assert + Assert.AreEqual(1, result.Total); + var item = result.Items.First(); + Assert.AreEqual("external@test.com", item.Email); + Assert.IsTrue(item.IsExternalOnly); + Assert.AreEqual(MemberKind.ExternalOnly, item.Kind); + Assert.IsNull(item.MemberTypeKey); + Assert.IsNull(item.MemberTypeIcon); + } + + [Test] + public async Task Filter_Returns_Both_Content_And_External_Members() + { + // Arrange + await CreateContentMemberAsync("content@test.com", "content-user"); + await CreateExternalMemberAsync("external@test.com", "external-user"); + + // Act + PagedModel result = await MemberFilterService.FilterAsync(new MemberFilter()); + + // Assert + Assert.AreEqual(2, result.Total); + Assert.IsTrue(result.Items.Any(i => !i.IsExternalOnly)); + Assert.IsTrue(result.Items.Any(i => i.IsExternalOnly)); + } + + [Test] + public async Task Filter_Paginates_Correctly_Across_Both_Stores() + { + // Arrange — create 3 members total (2 content, 1 external) so we can page through them. + await CreateContentMemberAsync("a-content@test.com", "a-content"); + await CreateExternalMemberAsync("b-external@test.com", "b-external"); + await CreateContentMemberAsync("c-content@test.com", "c-content"); + + // Act — get page 1 (take 2). + PagedModel page1 = await MemberFilterService.FilterAsync(new MemberFilter(), orderBy: "username", skip: 0, take: 2); + + // Act — get page 2 (take 2). + PagedModel page2 = await MemberFilterService.FilterAsync(new MemberFilter(), orderBy: "username", skip: 2, take: 2); + + // Assert + Assert.AreEqual(3, page1.Total); + Assert.AreEqual(2, page1.Items.Count()); + Assert.AreEqual(3, page2.Total); + Assert.AreEqual(1, page2.Items.Count()); + + // All 3 members should appear across both pages with no duplicates. + var allUsernames = page1.Items.Concat(page2.Items).Select(i => i.UserName).ToList(); + Assert.AreEqual(3, allUsernames.Distinct().Count()); + } + + [Test] + public async Task Filter_Orders_By_Username_Across_Both_Stores() + { + // Arrange — create members with known ordering. + await CreateContentMemberAsync("z-content@test.com", "z-content"); + await CreateExternalMemberAsync("a-external@test.com", "a-external"); + await CreateContentMemberAsync("m-content@test.com", "m-content"); + + // Act + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter(), orderBy: "username", orderDirection: Direction.Ascending); + + // Assert — should be sorted: a-external, m-content, z-content. + var usernames = result.Items.Select(i => i.UserName).ToList(); + Assert.AreEqual("a-external", usernames[0]); + Assert.AreEqual("m-content", usernames[1]); + Assert.AreEqual("z-content", usernames[2]); + } + + [Test] + public async Task Filter_By_MemberTypeId_Excludes_External_Members() + { + // Arrange + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + IMember contentMember = MemberBuilder.CreateSimpleMember(memberType, "content", "content@test.com", "password", "content-user"); + MemberService.Save(contentMember); + + await CreateExternalMemberAsync("external@test.com", "external-user"); + + // Act — filter by the content member's type ID. + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter { MemberTypeId = memberType.Key }); + + // Assert — only the content member should be returned. + Assert.AreEqual(1, result.Total); + Assert.IsFalse(result.Items.First().IsExternalOnly); + } + + [Test] + public async Task Filter_By_IsApproved_Applies_To_Both_Stores() + { + // Arrange + await CreateContentMemberAsync("approved-content@test.com", "approved-content"); + + var unapprovedExternal = new ExternalMemberIdentityBuilder() + .WithEmail("unapproved@test.com") + .WithUserName("unapproved-external") + .WithIsApproved(false) + .Build(); + await ExternalMemberService.CreateAsync(unapprovedExternal); + + // Act — filter for approved only. + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter { IsApproved = true }); + + // Assert — only the approved content member. + Assert.AreEqual(1, result.Total); + Assert.AreEqual("approved-content", result.Items.First().UserName); + } + + [Test] + public async Task Filter_By_Text_Searches_Both_Stores() + { + // Arrange + await CreateContentMemberAsync("alice-content@test.com", "alice-content"); + await CreateExternalMemberAsync("alice-external@test.com", "alice-external"); + await CreateContentMemberAsync("bob@test.com", "bob-content"); + + // Act — search for "alice". + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter { Filter = "alice" }); + + // Assert — both Alice members, not Bob. + Assert.AreEqual(2, result.Total); + Assert.IsTrue(result.Items.All(i => i.UserName.Contains("alice"))); + } + + [Test] + public async Task Filter_By_MemberTypeId_And_MemberGroupName() + { + // Arrange — create a content member in a specific type and group. + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + IMember contentMember = MemberBuilder.CreateSimpleMember(memberType, "grouped", "grouped@test.com", "password", "grouped-user"); + MemberService.Save(contentMember); + + MemberService.AddRole("FilterTestGroup"); + MemberService.AssignRoles([contentMember.Id], ["FilterTestGroup"]); + + // Create another content member of the same type but NOT in the group. + IMember ungroupedMember = MemberBuilder.CreateSimpleMember(memberType, "ungrouped", "ungrouped@test.com", "password", "ungrouped-user"); + MemberService.Save(ungroupedMember); + + // Create an external member in the group (should be excluded by type filter). + await CreateExternalMemberAsync("external@test.com", "external-user"); + await ExternalMemberService.AssignRolesAsync( + (await ExternalMemberService.GetByUsernameAsync("external-user"))!.Key, ["FilterTestGroup"]); + + // Act — filter by both type and group. + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter { MemberTypeId = memberType.Key, MemberGroupName = "FilterTestGroup" }); + + // Assert — only the content member that matches both type AND group. + Assert.AreEqual(1, result.Total); + Assert.AreEqual("grouped-user", result.Items.First().UserName); + Assert.IsFalse(result.Items.First().IsExternalOnly); + } + + [Test] + public async Task Filter_By_MemberGroupName_And_IsApproved() + { + // Arrange — create members in a group with different approval states. + MemberService.AddRole("ApprovalTestGroup"); + + await CreateContentMemberAsync("approved@test.com", "approved-content"); + IMember? approvedMember = MemberService.GetByEmail("approved@test.com"); + MemberService.AssignRoles([approvedMember!.Id], ["ApprovalTestGroup"]); + + var unapprovedExternal = new ExternalMemberIdentityBuilder() + .WithEmail("unapproved@test.com") + .WithUserName("unapproved-external") + .WithIsApproved(false) + .Build(); + var createResult = await ExternalMemberService.CreateAsync(unapprovedExternal); + await ExternalMemberService.AssignRolesAsync(createResult.Result!.Key, ["ApprovalTestGroup"]); + + // Act — filter by group AND approved. + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter { MemberGroupName = "ApprovalTestGroup", IsApproved = true }); + + // Assert — only the approved content member. + Assert.AreEqual(1, result.Total); + Assert.AreEqual("approved-content", result.Items.First().UserName); + } + + [Test] + public async Task Filter_By_MemberGroupName_Returns_Both_Stores() + { + // Arrange — create members in the same group across both stores. + MemberService.AddRole("SharedGroup"); + + await CreateContentMemberAsync("content@test.com", "content-user"); + IMember? contentMember = MemberService.GetByEmail("content@test.com"); + MemberService.AssignRoles([contentMember!.Id], ["SharedGroup"]); + + await CreateExternalMemberAsync("external@test.com", "external-user"); + var externalMember = await ExternalMemberService.GetByUsernameAsync("external-user"); + await ExternalMemberService.AssignRolesAsync(externalMember!.Key, ["SharedGroup"]); + + // Act + PagedModel result = await MemberFilterService.FilterAsync( + new MemberFilter { MemberGroupName = "SharedGroup" }); + + // Assert — both members from the shared group. + Assert.AreEqual(2, result.Total); + Assert.IsTrue(result.Items.Any(i => !i.IsExternalOnly)); + Assert.IsTrue(result.Items.Any(i => i.IsExternalOnly)); + } + + [Test] + public async Task Filter_Empty_Returns_Empty() + { + // Act + PagedModel result = await MemberFilterService.FilterAsync(new MemberFilter()); + + // Assert + Assert.AreEqual(0, result.Total); + Assert.IsFalse(result.Items.Any()); + } + + private async Task CreateContentMemberAsync(string email, string username) + { + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + IMember member = MemberBuilder.CreateSimpleMember(memberType, username, email, "password123!", username); + MemberService.Save(member); + } + + private async Task CreateExternalMemberAsync(string email, string username) + { + var identity = new ExternalMemberIdentityBuilder() + .WithEmail(email) + .WithUserName(username) + .WithName(username) + .Build(); + await ExternalMemberService.CreateAsync(identity); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactoryTests.cs new file mode 100644 index 000000000000..8c54ee189218 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactoryTests.cs @@ -0,0 +1,436 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Member; +using Umbraco.Cms.Api.Management.ViewModels.Member.Item; +using Umbraco.Cms.Api.Management.ViewModels.MemberType; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Factories; + +[TestFixture] +public class MemberPresentationFactoryTests +{ + private Mock _mockMapper = null!; + private Mock _mockMemberService = null!; + private Mock _mockMemberTypeService = null!; + private Mock _mockTwoFactorLoginService = null!; + private Mock _mockMemberGroupService = null!; + private Mock _mockExternalMemberService = null!; + private MemberPresentationFactory _sut = null!; + + [SetUp] + public void SetUp() + { + _mockMapper = new Mock(); + _mockMemberService = new Mock(); + _mockMemberTypeService = new Mock(); + _mockTwoFactorLoginService = new Mock(); + _mockMemberGroupService = new Mock(); + _mockExternalMemberService = new Mock(); + + _sut = new MemberPresentationFactory( + _mockMapper.Object, + _mockMemberService.Object, + _mockMemberTypeService.Object, + _mockTwoFactorLoginService.Object, + _mockMemberGroupService.Object, + Options.Create(new DeliveryApiSettings()), + _mockExternalMemberService.Object); + } + + [Test] + public async Task CreateResponseModelAsync_Maps_Member_Via_UmbracoMapper() + { + // Arrange + var memberKey = Guid.NewGuid(); + var member = CreateMockMember(memberKey, "content@test.com", "content"); + var mappedModel = new MemberResponseModel { Id = memberKey, Email = "content@test.com" }; + _mockMapper.Setup(x => x.Map(member.Object)).Returns(mappedModel); + _mockMemberService.Setup(x => x.GetAllRoles("content")).Returns(Enumerable.Empty()); + var user = CreateMockUser(hasSensitiveAccess: true); + + // Act + MemberResponseModel result = await _sut.CreateResponseModelAsync(member.Object, user.Object); + + // Assert + Assert.AreEqual(memberKey, result.Id); + Assert.AreEqual("content@test.com", result.Email); + _mockMapper.Verify(x => x.Map(member.Object), Times.Once); + } + + [Test] + public async Task CreateResponseModelAsync_Checks_TwoFactor_Status() + { + // Arrange + var memberKey = Guid.NewGuid(); + var member = CreateMockMember(memberKey, "2fa@test.com", "2fa-user"); + var mappedModel = new MemberResponseModel { Id = memberKey }; + _mockMapper.Setup(x => x.Map(member.Object)).Returns(mappedModel); + _mockMemberService.Setup(x => x.GetAllRoles("2fa-user")).Returns(Enumerable.Empty()); + _mockTwoFactorLoginService.Setup(x => x.IsTwoFactorEnabledAsync(memberKey)).ReturnsAsync(true); + var user = CreateMockUser(hasSensitiveAccess: true); + + // Act + MemberResponseModel result = await _sut.CreateResponseModelAsync(member.Object, user.Object); + + // Assert + Assert.IsTrue(result.IsTwoFactorEnabled); + } + + [Test] + public async Task CreateResponseModelAsync_Returns_Default_Kind_For_Regular_Member() + { + // Arrange + var memberKey = Guid.NewGuid(); + var member = CreateMockMember(memberKey, "regular@test.com", "regular"); + var mappedModel = new MemberResponseModel { Id = memberKey }; + _mockMapper.Setup(x => x.Map(member.Object)).Returns(mappedModel); + _mockMemberService.Setup(x => x.GetAllRoles("regular")).Returns(Enumerable.Empty()); + var user = CreateMockUser(hasSensitiveAccess: true); + + // Act + MemberResponseModel result = await _sut.CreateResponseModelAsync(member.Object, user.Object); + + // Assert + Assert.AreEqual(MemberKind.Default, result.Kind); + } + + [Test] + public async Task CreateResponseModelAsync_Resolves_Group_Keys_From_Roles() + { + // Arrange + var memberKey = Guid.NewGuid(); + var groupKey = Guid.NewGuid(); + var member = CreateMockMember(memberKey, "roles@test.com", "roles-user"); + var mappedModel = new MemberResponseModel { Id = memberKey }; + _mockMapper.Setup(x => x.Map(member.Object)).Returns(mappedModel); + _mockMemberService.Setup(x => x.GetAllRoles("roles-user")).Returns(new[] { "Editors" }); + var mockGroup = new Mock(); + mockGroup.Setup(x => x.Key).Returns(groupKey); + _mockMemberGroupService.Setup(x => x.GetByName("Editors")).Returns(mockGroup.Object); + var user = CreateMockUser(hasSensitiveAccess: true); + + // Act + MemberResponseModel result = await _sut.CreateResponseModelAsync(member.Object, user.Object); + + // Assert + CollectionAssert.Contains(result.Groups.ToList(), groupKey); + } + + [Test] + public async Task CreateResponseModelAsync_Removes_Sensitive_Data_When_User_Lacks_Access() + { + // Arrange + var memberKey = Guid.NewGuid(); + var memberTypeKey = Guid.NewGuid(); + var member = CreateMockMember(memberKey, "sensitive@test.com", "sensitive-user"); + member.Setup(x => x.ContentType.Key).Returns(memberTypeKey); + + var mappedModel = new MemberResponseModel + { + Id = memberKey, + IsApproved = true, + IsLockedOut = true, + LastLoginDate = DateTimeOffset.UtcNow, + Values = Enumerable.Empty(), + }; + _mockMapper.Setup(x => x.Map(member.Object)).Returns(mappedModel); + _mockMemberService.Setup(x => x.GetAllRoles("sensitive-user")).Returns(Enumerable.Empty()); + + var mockMemberType = new Mock(); + mockMemberType.Setup(x => x.PropertyTypes).Returns(Enumerable.Empty()); + _mockMemberTypeService.Setup(x => x.GetAsync(memberTypeKey)).ReturnsAsync(mockMemberType.Object); + + var user = CreateMockUser(hasSensitiveAccess: false); + + // Act + MemberResponseModel result = await _sut.CreateResponseModelAsync(member.Object, user.Object); + + // Assert — sensitive fields are reset. + Assert.IsFalse(result.IsApproved); + Assert.IsFalse(result.IsLockedOut); + Assert.IsNull(result.LastLoginDate); + } + + [Test] + public async Task CreateMultipleAsync_Returns_Model_Per_Member() + { + // Arrange + var members = new[] + { + CreateMockMember(Guid.NewGuid(), "a@test.com", "a"), + CreateMockMember(Guid.NewGuid(), "b@test.com", "b"), + }; + + foreach (var m in members) + { + _mockMapper.Setup(x => x.Map(m.Object)) + .Returns(new MemberResponseModel { Id = m.Object.Key }); + _mockMemberService.Setup(x => x.GetAllRoles(m.Object.Username)).Returns(Enumerable.Empty()); + } + + var user = CreateMockUser(hasSensitiveAccess: true); + + // Act + IEnumerable results = await _sut.CreateMultipleAsync( + members.Select(m => m.Object), user.Object); + + // Assert + Assert.AreEqual(2, results.Count()); + } + + [Test] + public void CreateItemResponseModel_IMember_Sets_Key_And_Kind() + { + // Arrange + var memberKey = Guid.NewGuid(); + var member = CreateMockMember(memberKey, "item@test.com", "item-user"); + _mockMapper.Setup(x => x.Map(member.Object)) + .Returns(new MemberTypeReferenceResponseModel()); + + // Act + MemberItemResponseModel result = _sut.CreateItemResponseModel(member.Object); + + // Assert + Assert.AreEqual(memberKey, result.Id); + Assert.AreEqual(MemberKind.Default, result.Kind); + } + + [Test] + public void CreateItemResponseModel_IMember_Includes_Name_In_Variants() + { + // Arrange + var member = CreateMockMember(Guid.NewGuid(), "variants@test.com", "variants-user"); + member.Setup(x => x.Name).Returns("Test Name"); + _mockMapper.Setup(x => x.Map(member.Object)) + .Returns(new MemberTypeReferenceResponseModel()); + + // Act + MemberItemResponseModel result = _sut.CreateItemResponseModel(member.Object); + + // Assert + Assert.AreEqual(1, result.Variants.Count()); + Assert.AreEqual("Test Name", result.Variants.First().Name); + Assert.IsNull(result.Variants.First().Culture); + } + + [Test] + public async Task CreateExternalMemberResponseModel_Returns_Model_With_ExternalOnly_Kind() + { + // Arrange + var member = CreateExternalMember(); + _mockExternalMemberService.Setup(x => x.GetRolesAsync(member.Key)).ReturnsAsync(Enumerable.Empty()); + + // Act + MemberResponseModel result = await _sut.CreateExternalMemberResponseModelAsync(member); + + // Assert + Assert.AreEqual(MemberKind.ExternalOnly, result.Kind); + } + + [Test] + public async Task CreateExternalMemberResponseModel_Maps_Identity_Fields() + { + // Arrange + var member = CreateExternalMember(); + _mockExternalMemberService.Setup(x => x.GetRolesAsync(member.Key)).ReturnsAsync(Enumerable.Empty()); + + // Act + MemberResponseModel result = await _sut.CreateExternalMemberResponseModelAsync(member); + + // Assert + Assert.AreEqual(member.Key, result.Id); + Assert.AreEqual("external@test.com", result.Email); + Assert.AreEqual("external@test.com", result.Username); + Assert.IsTrue(result.IsApproved); + Assert.IsFalse(result.IsLockedOut); + } + + [Test] + public async Task CreateExternalMemberResponseModel_Has_Single_Variant_And_Empty_Values() + { + // Arrange + var member = CreateExternalMember(); + _mockExternalMemberService.Setup(x => x.GetRolesAsync(member.Key)).ReturnsAsync(Enumerable.Empty()); + + // Act + MemberResponseModel result = await _sut.CreateExternalMemberResponseModelAsync(member); + + // Assert — one variant with the member name, but no content values. + Assert.AreEqual(1, result.Variants.Count()); + Assert.AreEqual(member.Name, result.Variants.First().Name); + Assert.IsFalse(result.Values.Any()); + } + + [Test] + public async Task CreateExternalMemberResponseModel_Resolves_Group_Keys() + { + // Arrange + var member = CreateExternalMember(); + var groupKey = Guid.NewGuid(); + var mockGroup = new Mock(); + mockGroup.Setup(x => x.Key).Returns(groupKey); + + _mockExternalMemberService.Setup(x => x.GetRolesAsync(member.Key)).ReturnsAsync(new[] { "TestGroup" }); + _mockMemberGroupService.Setup(x => x.GetByName("TestGroup")).Returns(mockGroup.Object); + + // Act + MemberResponseModel result = await _sut.CreateExternalMemberResponseModelAsync(member); + + // Assert + CollectionAssert.Contains(result.Groups.ToList(), groupKey); + } + + [Test] + public async Task CreateExternalMemberResponseModel_TwoFactor_Is_Disabled() + { + // Arrange + var member = CreateExternalMember(); + _mockExternalMemberService.Setup(x => x.GetRolesAsync(member.Key)).ReturnsAsync(Enumerable.Empty()); + + // Act + MemberResponseModel result = await _sut.CreateExternalMemberResponseModelAsync(member); + + // Assert + Assert.IsFalse(result.IsTwoFactorEnabled); + } + + [Test] + public async Task CreateExternalMemberResponseModel_Maps_Login_Dates() + { + // Arrange + var loginDate = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var lockoutDate = new DateTime(2026, 1, 10, 8, 0, 0, DateTimeKind.Utc); + var member = new ExternalMemberIdentity + { + Key = Guid.NewGuid(), + Email = "dates@test.com", + UserName = "dates@test.com", + Name = "Dates Test", + IsApproved = true, + CreateDate = DateTime.UtcNow, + LastLoginDate = loginDate, + LastLockoutDate = lockoutDate, + }; + _mockExternalMemberService.Setup(x => x.GetRolesAsync(member.Key)).ReturnsAsync(Enumerable.Empty()); + + // Act + MemberResponseModel result = await _sut.CreateExternalMemberResponseModelAsync(member); + + // Assert + Assert.AreEqual(loginDate, result.LastLoginDate!.Value.UtcDateTime); + Assert.AreEqual(lockoutDate, result.LastLockoutDate!.Value.UtcDateTime); + Assert.IsNull(result.LastPasswordChangeDate); + } + + private static Mock CreateMockMember(Guid key, string email, string username) + { + var contentType = new Mock(); + contentType.Setup(x => x.Key).Returns(Guid.NewGuid()); + contentType.Setup(x => x.Alias).Returns("Member"); + + var member = new Mock(); + member.Setup(x => x.Key).Returns(key); + member.Setup(x => x.Email).Returns(email); + member.Setup(x => x.Username).Returns(username); + member.Setup(x => x.Name).Returns(username); + member.Setup(x => x.ContentType).Returns(contentType.Object); + return member; + } + + private static Mock CreateMockUser(bool hasSensitiveAccess) + { + var user = new Mock(); + var groups = new List(); + if (hasSensitiveAccess) + { + var group = new Mock(); + group.Setup(x => x.Key).Returns(global::Umbraco.Cms.Core.Constants.Security.SensitiveDataGroupKey); + groups.Add(group.Object); + } + + user.Setup(x => x.Groups).Returns(groups); + return user; + } + + // --- CreateFilterItemResponseModel --- + + [Test] + public void CreateFilterItemResponseModel_Maps_Content_Member_With_Type() + { + // Arrange + var memberTypeKey = Guid.NewGuid(); + var item = new MemberFilterItem + { + Key = Guid.NewGuid(), + Email = "filter-content@test.com", + UserName = "filter-content", + Name = "Filter Content", + IsApproved = true, + Kind = MemberKind.Default, + MemberTypeKey = memberTypeKey, + MemberTypeName = "Member", + MemberTypeIcon = "icon-user", + }; + + // Act + MemberResponseModel result = _sut.CreateFilterItemResponseModel(item); + + // Assert + Assert.AreEqual(item.Key, result.Id); + Assert.AreEqual("filter-content@test.com", result.Email); + Assert.AreEqual(MemberKind.Default, result.Kind); + Assert.AreEqual(memberTypeKey, result.MemberType.Id); + Assert.AreEqual("icon-user", result.MemberType.Icon); + Assert.AreEqual("Filter Content", result.Variants.First().Name); + } + + [Test] + public void CreateFilterItemResponseModel_Maps_External_Member_With_Empty_Type() + { + // Arrange + var item = new MemberFilterItem + { + Key = Guid.NewGuid(), + Email = "filter-ext@test.com", + UserName = "filter-ext", + Name = "Filter External", + IsApproved = true, + Kind = MemberKind.ExternalOnly, + MemberTypeKey = null, + MemberTypeName = null, + MemberTypeIcon = null, + }; + + // Act + MemberResponseModel result = _sut.CreateFilterItemResponseModel(item); + + // Assert + Assert.AreEqual(item.Key, result.Id); + Assert.AreEqual(MemberKind.ExternalOnly, result.Kind); + Assert.AreEqual(Guid.Empty, result.MemberType.Id); + Assert.AreEqual(string.Empty, result.MemberType.Icon); + } + + private static ExternalMemberIdentity CreateExternalMember() => new() + { + Key = Guid.NewGuid(), + Email = "external@test.com", + UserName = "external@test.com", + Name = "External Test", + IsApproved = true, + CreateDate = DateTime.UtcNow, + SecurityStamp = Guid.NewGuid().ToString(), + }; +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/MemberPresentationServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/MemberPresentationServiceTests.cs new file mode 100644 index 000000000000..e3049db14f3f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/MemberPresentationServiceTests.cs @@ -0,0 +1,182 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Api.Management.ViewModels.Member; +using Umbraco.Cms.Api.Management.ViewModels.Member.Item; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services; + +[TestFixture] +public class MemberPresentationServiceTests +{ + private Mock _mockEntityService = null!; + private Mock _mockMemberEditingService = null!; + private Mock _mockFactory = null!; + private MemberPresentationService _sut = null!; + + [SetUp] + public void SetUp() + { + _mockEntityService = new Mock(); + _mockMemberEditingService = new Mock(); + _mockFactory = new Mock(); + + _sut = new MemberPresentationService( + _mockEntityService.Object, + _mockMemberEditingService.Object, + _mockFactory.Object); + } + + [Test] + public async Task CreateResponseModelByKeyAsync_Returns_Content_Member_When_Found() + { + // Arrange + var id = Guid.NewGuid(); + var member = new Mock(); + var user = new Mock(); + var expected = new MemberResponseModel { Id = id }; + + _mockMemberEditingService.Setup(x => x.GetAsync(id)).ReturnsAsync(member.Object); + _mockFactory.Setup(x => x.CreateResponseModelAsync(member.Object, user.Object)).ReturnsAsync(expected); + + // Act + MemberResponseModel? result = await _sut.CreateResponseModelByKeyAsync(id, user.Object); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(id, result!.Id); + _mockMemberEditingService.Verify(x => x.GetExternalMemberAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateResponseModelByKeyAsync_Falls_Back_To_External_Member_When_Content_Not_Found() + { + // Arrange + var id = Guid.NewGuid(); + var externalMember = new ExternalMemberIdentity { Key = id, Email = "ext@test.com" }; + var user = new Mock(); + var expected = new MemberResponseModel { Id = id, Kind = MemberKind.ExternalOnly }; + + _mockMemberEditingService.Setup(x => x.GetAsync(id)).ReturnsAsync((IMember?)null); + _mockMemberEditingService.Setup(x => x.GetExternalMemberAsync(id)).ReturnsAsync(externalMember); + _mockFactory.Setup(x => x.CreateExternalMemberResponseModelAsync(externalMember)).ReturnsAsync(expected); + + // Act + MemberResponseModel? result = await _sut.CreateResponseModelByKeyAsync(id, user.Object); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(MemberKind.ExternalOnly, result!.Kind); + } + + [Test] + public async Task CreateResponseModelByKeyAsync_Returns_Null_When_Not_Found_In_Either_Store() + { + // Arrange + var id = Guid.NewGuid(); + var user = new Mock(); + + _mockMemberEditingService.Setup(x => x.GetAsync(id)).ReturnsAsync((IMember?)null); + _mockMemberEditingService.Setup(x => x.GetExternalMemberAsync(id)).ReturnsAsync((ExternalMemberIdentity?)null); + + // Act + MemberResponseModel? result = await _sut.CreateResponseModelByKeyAsync(id, user.Object); + + // Assert + Assert.IsNull(result); + } + + [Test] + public async Task CreateItemResponseModelsAsync_Returns_Content_Members() + { + // Arrange + var id = Guid.NewGuid(); + var entity = CreateMockMemberEntitySlim(id); + var expected = new MemberItemResponseModel { Id = id }; + + _mockEntityService + .Setup(x => x.GetAll(UmbracoObjectTypes.Member, It.Is(a => a.Contains(id)))) + .Returns([entity.Object]); + _mockFactory.Setup(x => x.CreateItemResponseModel(entity.Object)).Returns(expected); + + // Act + IEnumerable results = await _sut.CreateItemResponseModelsAsync([id]); + + // Assert + Assert.AreEqual(1, results.Count()); + Assert.AreEqual(id, results.First().Id); + } + + [Test] + public async Task CreateItemResponseModelsAsync_Resolves_External_Members_For_Unresolved_Ids() + { + // Arrange + var contentId = Guid.NewGuid(); + var externalId = Guid.NewGuid(); + var contentEntity = CreateMockMemberEntitySlim(contentId); + var externalMember = new ExternalMemberIdentity { Key = externalId }; + var contentItem = new MemberItemResponseModel { Id = contentId }; + var externalItem = new MemberItemResponseModel { Id = externalId, Kind = MemberKind.ExternalOnly }; + + _mockEntityService + .Setup(x => x.GetAll(UmbracoObjectTypes.Member, It.IsAny())) + .Returns(new[] { contentEntity.Object }); + _mockFactory.Setup(x => x.CreateItemResponseModel(contentEntity.Object)).Returns(contentItem); + _mockMemberEditingService.Setup(x => x.GetExternalMemberAsync(externalId)).ReturnsAsync(externalMember); + _mockFactory.Setup(x => x.CreateExternalMemberItemResponseModel(externalMember)).Returns(externalItem); + + // Act + IEnumerable results = + await _sut.CreateItemResponseModelsAsync(new HashSet { contentId, externalId }); + + // Assert + Assert.AreEqual(2, results.Count()); + Assert.IsTrue(results.Any(r => r.Id == contentId)); + Assert.IsTrue(results.Any(r => r.Id == externalId && r.Kind == MemberKind.ExternalOnly)); + } + + [Test] + public async Task CreateItemResponseModelsAsync_Skips_Unresolved_Ids_Not_In_External_Store() + { + // Arrange + var unknownId = Guid.NewGuid(); + + _mockEntityService + .Setup(x => x.GetAll(UmbracoObjectTypes.Member, It.IsAny())) + .Returns(Enumerable.Empty()); + _mockMemberEditingService.Setup(x => x.GetExternalMemberAsync(unknownId)).ReturnsAsync((ExternalMemberIdentity?)null); + + // Act + IEnumerable results = + await _sut.CreateItemResponseModelsAsync(new HashSet { unknownId }); + + // Assert + Assert.IsEmpty(results); + } + + [Test] + public async Task CreateItemResponseModelsAsync_Returns_Empty_For_Empty_Ids() + { + // Act + IEnumerable results = + await _sut.CreateItemResponseModelsAsync(new HashSet()); + + // Assert + Assert.IsEmpty(results); + _mockEntityService.Verify( + x => x.GetAll(UmbracoObjectTypes.Member, It.IsAny()), Times.Once); + } + + private static Mock CreateMockMemberEntitySlim(Guid key) + { + var entity = new Mock(); + entity.Setup(x => x.Key).Returns(key); + return entity; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/MemberReferenceServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/MemberReferenceServiceTests.cs new file mode 100644 index 000000000000..8f89d079b488 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Services/MemberReferenceServiceTests.cs @@ -0,0 +1,154 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services; + +[TestFixture] +public class MemberReferenceServiceTests +{ + private Mock _mockTrackedReferencesService = null!; + private Mock _mockMemberEditingService = null!; + private MemberReferenceService _sut = null!; + + [SetUp] + public void SetUp() + { + _mockTrackedReferencesService = new Mock(); + _mockMemberEditingService = new Mock(); + + _sut = new MemberReferenceService( + _mockTrackedReferencesService.Object, + _mockMemberEditingService.Object); + } + + [Test] + public async Task GetPagedReferencesAsync_Returns_Success_When_Entity_Lookup_Succeeds() + { + // Arrange + var id = Guid.NewGuid(); + var pagedModel = new PagedModel { Total = 1, Items = new[] { new RelationItemModel() } }; + var attempt = Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, pagedModel); + + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, 0, 20, true)) + .ReturnsAsync(attempt); + + // Act + Attempt, GetReferencesOperationStatus> result = + await _sut.GetPagedReferencesAsync(id, 0, 20); + + // Assert + Assert.IsTrue(result.Success); + Assert.AreEqual(1, result.Result.Total); + _mockMemberEditingService.Verify(x => x.IsExternalMemberAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task GetPagedReferencesAsync_Falls_Back_For_External_Member_When_ContentNotFound() + { + // Arrange + var id = Guid.NewGuid(); + var failedAttempt = Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + var fallbackModel = new PagedModel { Total = 2, Items = new[] { new RelationItemModel(), new RelationItemModel() } }; + + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, 0, 10, true)) + .ReturnsAsync(failedAttempt); + _mockMemberEditingService.Setup(x => x.IsExternalMemberAsync(id)).ReturnsAsync(true); + +#pragma warning disable CS0618 // Type or member is obsolete + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, 0, 10, true)) + .ReturnsAsync(fallbackModel); +#pragma warning restore CS0618 + + // Act + Attempt, GetReferencesOperationStatus> result = + await _sut.GetPagedReferencesAsync(id, 0, 10); + + // Assert + Assert.IsTrue(result.Success); + Assert.AreEqual(GetReferencesOperationStatus.Success, result.Status); + Assert.AreEqual(2, result.Result.Total); + } + + [Test] + public async Task GetPagedReferencesAsync_Returns_ContentNotFound_When_Not_External_Member() + { + // Arrange + var id = Guid.NewGuid(); + var failedAttempt = Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, 0, 20, true)) + .ReturnsAsync(failedAttempt); + _mockMemberEditingService.Setup(x => x.IsExternalMemberAsync(id)).ReturnsAsync(false); + + // Act + Attempt, GetReferencesOperationStatus> result = + await _sut.GetPagedReferencesAsync(id, 0, 20); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(GetReferencesOperationStatus.ContentNotFound, result.Status); + } + + [Test] + public async Task GetPagedReferencesAsync_Does_Not_Check_External_Member_For_Non_ContentNotFound_Failure() + { + // Arrange + var id = Guid.NewGuid(); + + // Simulate a failure with a status other than ContentNotFound. + var failedAttempt = Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + + // We can't easily create a different status since the enum only has Success and ContentNotFound, + // so instead verify the external member check is only called for ContentNotFound. + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, 5, 10, true)) + .ReturnsAsync(failedAttempt); + _mockMemberEditingService.Setup(x => x.IsExternalMemberAsync(id)).ReturnsAsync(false); + + // Act + Attempt, GetReferencesOperationStatus> result = + await _sut.GetPagedReferencesAsync(id, 5, 10); + + // Assert + Assert.IsFalse(result.Success); + _mockMemberEditingService.Verify(x => x.IsExternalMemberAsync(id), Times.Once); + } + + [Test] + public async Task GetPagedReferencesAsync_Passes_Skip_And_Take_To_Fallback() + { + // Arrange + var id = Guid.NewGuid(); + var failedAttempt = Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + var fallbackModel = new PagedModel { Total = 0, Items = Array.Empty() }; + + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, 10, 5, true)) + .ReturnsAsync(failedAttempt); + _mockMemberEditingService.Setup(x => x.IsExternalMemberAsync(id)).ReturnsAsync(true); + +#pragma warning disable CS0618 // Type or member is obsolete + _mockTrackedReferencesService + .Setup(x => x.GetPagedRelationsForItemAsync(id, (long)10, (long)5, true)) + .ReturnsAsync(fallbackModel); +#pragma warning restore CS0618 + + // Act + await _sut.GetPagedReferencesAsync(id, 10, 5); + + // Assert +#pragma warning disable CS0618 // Type or member is obsolete + _mockTrackedReferencesService.Verify( + x => x.GetPagedRelationsForItemAsync(id, (long)10, (long)5, true), Times.Once); +#pragma warning restore CS0618 + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Handlers/AuditNotificationsHandlerMemberTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Handlers/AuditNotificationsHandlerMemberTests.cs new file mode 100644 index 000000000000..3ccddee3d3d5 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Handlers/AuditNotificationsHandlerMemberTests.cs @@ -0,0 +1,392 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Handlers; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Net; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Handlers; + +[TestFixture] +public class AuditNotificationsHandlerMemberTests +{ + private Mock _mockAuditEntryService = null!; + private Mock _mockMemberService = null!; + private AuditNotificationsHandler _sut = null!; + + [SetUp] + public void SetUp() + { + _mockAuditEntryService = new Mock(); + _mockMemberService = new Mock(); + + var mockIpResolver = new Mock(); + mockIpResolver.Setup(x => x.GetCurrentRequestIpAddress()).Returns("127.0.0.1"); + + var mockBackOfficeSecurity = new Mock(); + mockBackOfficeSecurity.Setup(x => x.CurrentUser).Returns((IUser?)null); + var mockBackOfficeSecurityAccessor = new Mock(); + mockBackOfficeSecurityAccessor.Setup(x => x.BackOfficeSecurity).Returns(mockBackOfficeSecurity.Object); + + _sut = new AuditNotificationsHandler( + _mockAuditEntryService.Object, + Mock.Of(), + Mock.Of(), + mockIpResolver.Object, + mockBackOfficeSecurityAccessor.Object, + _mockMemberService.Object, + Mock.Of()); + } + + [Test] + public async Task GivenAContentMember_WhenSaved_ThenAuditEntryWrittenWithMemberSaveEventType() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var member = new Member("Test Member", "test@example.com", "test", memberType) { Id = 123 }; + var notification = new MemberSavedNotification(member, new EventMessages()); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("Member") && s.Contains("Test Member") && s.Contains("test@example.com")), + "umbraco/member/save", + It.Is(s => s.StartsWith("updating"))), + Times.Once); + } + + [Test] + public async Task GivenAContentMember_WhenDeleted_ThenAuditEntryWrittenWithMemberDeleteEventType() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var member = new Member("Deleted Member", "deleted@example.com", "deleted", memberType) { Id = 456 }; + var notification = new MemberDeletedNotification(member, new EventMessages()); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("Member") && s.Contains("Deleted Member")), + "umbraco/member/delete", + It.Is(s => s.Contains("delete member"))), + Times.Once); + } + + [Test] + public async Task GivenAContentMember_WhenRolesAssigned_ThenAuditEntryWrittenWithRolesAssignedEventType() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var member = new Member("Role Member", "role@example.com", "role", memberType) { Id = 789 }; + _mockMemberService.Setup(x => x.GetAllMembers(It.IsAny())).Returns(new[] { (IMember)member }); + + var notification = new AssignedMemberRolesNotification(new[] { 789 }, new[] { "Editors" }); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("Member") && s.Contains("789")), + "umbraco/member/roles/assigned", + It.Is(s => s.Contains("Editors"))), + Times.Once); + } + + [Test] + public async Task GivenAContentMember_WhenRolesRemoved_ThenAuditEntryWrittenWithRolesRemovedEventType() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var member = new Member("Role Member", "role@example.com", "role", memberType) { Id = 789 }; + _mockMemberService.Setup(x => x.GetAllMembers(It.IsAny())).Returns(new[] { (IMember)member }); + + var notification = new RemovedMemberRolesNotification(new[] { 789 }, new[] { "Editors" }); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("Member")), + "umbraco/member/roles/removed", + It.Is(s => s.Contains("Editors"))), + Times.Once); + } + + [Test] + public async Task GivenAContentMember_WhenSaved_ThenAffectedDetailsStartsWithMember() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var member = new Member("Prefix Check", "prefix@example.com", "prefix", memberType) { Id = 100 }; + var notification = new MemberSavedNotification(member, new EventMessages()); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert — content member affected details start with "Member", not "External member". + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is(s => s.StartsWith("Member ") && !s.StartsWith("External")), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task GivenAnExternalMember_WhenSaved_ThenAuditEntryWrittenWithMemberSaveEventType() + { + // Arrange + var member = new ExternalMemberIdentity + { + Key = Guid.NewGuid(), + Email = "test@example.com", + UserName = "test@example.com", + Name = "Test External", + IsApproved = true, + CreateDate = DateTime.UtcNow, + }; + + var notification = new ExternalMemberSavedNotification(member, new EventMessages()); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("External member") && s.Contains("Test External") && s.Contains("test@example.com")), + "umbraco/member/save", + It.Is(s => s.Contains("updating external member"))), + Times.Once); + } + + [Test] + public async Task GivenAnExternalMember_WhenDeleted_ThenAuditEntryWrittenWithMemberDeleteEventType() + { + // Arrange + var member = new ExternalMemberIdentity + { + Key = Guid.NewGuid(), + Email = "deleted@example.com", + UserName = "deleted@example.com", + Name = "Deleted External", + CreateDate = DateTime.UtcNow, + }; + + var notification = new ExternalMemberDeletedNotification(member, new EventMessages()); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("External member") && s.Contains("Deleted External")), + "umbraco/member/delete", + It.Is(s => s.Contains("delete external member"))), + Times.Once); + } + + [Test] + public async Task GivenAnExternalMember_WhenRolesAssigned_ThenAuditEntryWrittenWithRolesAssignedEventType() + { + // Arrange + var memberKey = Guid.NewGuid(); + var notification = new AssignedExternalMemberRolesNotification( + new[] { memberKey }, + new[] { "Editors", "Writers" }); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("External member") && s.Contains(memberKey.ToString())), + "umbraco/member/roles/assigned", + It.Is(s => s.Contains("Editors") && s.Contains("Writers"))), + Times.Once); + } + + [Test] + public async Task GivenAnExternalMember_WhenRolesRemoved_ThenAuditEntryWrittenWithRolesRemovedEventType() + { + // Arrange + var memberKey = Guid.NewGuid(); + var notification = new RemovedExternalMemberRolesNotification( + new[] { memberKey }, + new[] { "Editors" }); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + "127.0.0.1", + It.IsAny(), + It.IsAny(), + It.Is(s => s.Contains("External member")), + "umbraco/member/roles/removed", + It.Is(s => s.Contains("Editors"))), + Times.Once); + } + + [Test] + public async Task GivenAnExternalMember_WhenSaved_ThenAffectedDetailsStartsWithExternalMember() + { + // Arrange + var member = new ExternalMemberIdentity + { + Key = Guid.NewGuid(), + Email = "prefix@example.com", + UserName = "prefix@example.com", + Name = "Prefix Test", + CreateDate = DateTime.UtcNow, + }; + + var notification = new ExternalMemberSavedNotification(member, new EventMessages()); + + // Act + await _sut.HandleAsync(notification, CancellationToken.None); + + // Assert + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is(s => s.StartsWith("External member")), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task ContentAndExternalMembers_UseSameEventTypeForSave() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var contentMember = new Member("Content", "content@example.com", "content", memberType) { Id = 1 }; + var externalMember = new ExternalMemberIdentity + { + Key = Guid.NewGuid(), + Email = "external@example.com", + UserName = "external@example.com", + Name = "External", + CreateDate = DateTime.UtcNow, + }; + + // Act + await _sut.HandleAsync(new MemberSavedNotification(contentMember, new EventMessages()), CancellationToken.None); + await _sut.HandleAsync(new ExternalMemberSavedNotification(externalMember, new EventMessages()), CancellationToken.None); + + // Assert — both use the same event type so audit queries for "umbraco/member/save" return both. + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + "umbraco/member/save", + It.IsAny()), + Times.Exactly(2)); + } + + [Test] + public async Task ContentAndExternalMembers_UseSameEventTypeForDelete() + { + // Arrange + IMemberType memberType = new MemberType(new MockShortStringHelper(), 77); + var contentMember = new Member("Content", "content@example.com", "content", memberType) { Id = 1 }; + var externalMember = new ExternalMemberIdentity + { + Key = Guid.NewGuid(), + Email = "external@example.com", + UserName = "external@example.com", + Name = "External", + CreateDate = DateTime.UtcNow, + }; + + // Act + await _sut.HandleAsync(new MemberDeletedNotification(contentMember, new EventMessages()), CancellationToken.None); + await _sut.HandleAsync(new ExternalMemberDeletedNotification(externalMember, new EventMessages()), CancellationToken.None); + + // Assert — both use "umbraco/member/delete". + _mockAuditEntryService.Verify( + x => x.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + "umbraco/member/delete", + It.IsAny()), + Times.Exactly(2)); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Search/ExternalMemberIndexingNotificationHandlerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Search/ExternalMemberIndexingNotificationHandlerTests.cs new file mode 100644 index 000000000000..ee8108faf316 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Search/ExternalMemberIndexingNotificationHandlerTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.Search; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Search; + +[TestFixture] +public class ExternalMemberIndexingNotificationHandlerTests +{ + private Mock _mockIndexingHandler = null!; + private Mock _mockExternalMemberService = null!; + private ExternalMemberIndexingNotificationHandler _sut = null!; + + [SetUp] + public void SetUp() + { + _mockIndexingHandler = new Mock(); + _mockIndexingHandler.Setup(x => x.Enabled).Returns(true); + _mockExternalMemberService = new Mock(); + + _sut = new ExternalMemberIndexingNotificationHandler( + _mockExternalMemberService.Object, + _mockIndexingHandler.Object); + } + + [Test] + public void GivenRefreshByPayload_WhenMemberNotRemoved_ThenReIndexIsCalled() + { + // Arrange + var key = Guid.NewGuid(); + var member = new ExternalMemberIdentity { Key = key, Email = "test@example.com", UserName = "test", Name = "Test" }; + _mockExternalMemberService.Setup(x => x.GetByKeyAsync(key)).ReturnsAsync(member); + + var payload = new[] { new ExternalMemberCacheRefresher.JsonPayload(42, key, removed: false) }; + var notification = new ExternalMemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(member), Times.Once); + _mockIndexingHandler.Verify(x => x.DeleteExternalMemberFromIndex(It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenMemberRemoved_ThenDeleteIsCalled() + { + // Arrange + var payload = new[] { new ExternalMemberCacheRefresher.JsonPayload(42, Guid.NewGuid(), removed: true) }; + var notification = new ExternalMemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.DeleteExternalMemberFromIndex(42), Times.Once); + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(It.IsAny()), Times.Never); + _mockExternalMemberService.Verify(x => x.GetByKeyAsync(It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenIndexingDisabled_ThenNothingHappens() + { + // Arrange + _mockIndexingHandler.Setup(x => x.Enabled).Returns(false); + + var payload = new[] { new ExternalMemberCacheRefresher.JsonPayload(42, Guid.NewGuid(), removed: false) }; + var notification = new ExternalMemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockExternalMemberService.Verify(x => x.GetByKeyAsync(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.DeleteExternalMemberFromIndex(It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenMemberLookupReturnsNull_ThenReIndexNotCalled() + { + // Arrange + _mockExternalMemberService.Setup(x => x.GetByKeyAsync(It.IsAny())).ReturnsAsync((ExternalMemberIdentity?)null); + + var payload = new[] { new ExternalMemberCacheRefresher.JsonPayload(42, Guid.NewGuid(), removed: false) }; + var notification = new ExternalMemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.DeleteExternalMemberFromIndex(It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenIndexableFieldsUnchanged_ThenReIndexNotCalled() + { + // Arrange + var payload = new[] + { + new ExternalMemberCacheRefresher.JsonPayload(42, Guid.NewGuid(), removed: false, indexableFieldsChanged: false), + }; + var notification = new ExternalMemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockExternalMemberService.Verify(x => x.GetByKeyAsync(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.DeleteExternalMemberFromIndex(It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WithMultiplePayloads_ThenEachIsProcessed() + { + // Arrange + var key1 = Guid.NewGuid(); + var key3 = Guid.NewGuid(); + var member1 = new ExternalMemberIdentity { Key = key1, Email = "one@example.com", UserName = "one", Name = "One" }; + var member3 = new ExternalMemberIdentity { Key = key3, Email = "three@example.com", UserName = "three", Name = "Three" }; + _mockExternalMemberService.Setup(x => x.GetByKeyAsync(key1)).ReturnsAsync(member1); + _mockExternalMemberService.Setup(x => x.GetByKeyAsync(key3)).ReturnsAsync(member3); + + var payload = new[] + { + new ExternalMemberCacheRefresher.JsonPayload(1, key1, removed: false), + new ExternalMemberCacheRefresher.JsonPayload(2, Guid.NewGuid(), removed: true), + new ExternalMemberCacheRefresher.JsonPayload(3, key3, removed: false), + }; + var notification = new ExternalMemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(member1), Times.Once); + _mockIndexingHandler.Verify(x => x.ReIndexForExternalMember(member3), Times.Once); + _mockIndexingHandler.Verify(x => x.DeleteExternalMemberFromIndex(2), Times.Once); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandlerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandlerTests.cs new file mode 100644 index 000000000000..5ab885756c7b --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Search/MemberIndexingNotificationHandlerTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.Search; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Search; + +[TestFixture] +public class MemberIndexingNotificationHandlerTests +{ + private Mock _mockIndexingHandler = null!; + private Mock _mockMemberService = null!; + private MemberIndexingNotificationHandler _sut = null!; + + [SetUp] + public void SetUp() + { + _mockIndexingHandler = new Mock(); + _mockIndexingHandler.Setup(x => x.Enabled).Returns(true); + _mockMemberService = new Mock(); + + _sut = new MemberIndexingNotificationHandler(_mockIndexingHandler.Object, _mockMemberService.Object); + } + + [Test] + public void GivenRefreshByPayload_WhenMemberNotRemoved_ThenReIndexIsCalled() + { + // Arrange + var member = Mock.Of(); + _mockMemberService.Setup(x => x.GetById(42)).Returns(member); + + var payload = new[] { new MemberCacheRefresher.JsonPayload(42, "test", removed: false) }; + var notification = new MemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.ReIndexForMember(member), Times.Once); + _mockIndexingHandler.Verify(x => x.DeleteIndexForEntity(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenMemberRemoved_ThenDeleteIsCalled() + { + // Arrange + var payload = new[] { new MemberCacheRefresher.JsonPayload(42, "test", removed: true) }; + var notification = new MemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.DeleteIndexForEntity(42, false), Times.Once); + _mockIndexingHandler.Verify(x => x.ReIndexForMember(It.IsAny()), Times.Never); + _mockMemberService.Verify(x => x.GetById(It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenIndexingDisabled_ThenNothingHappens() + { + // Arrange + _mockIndexingHandler.Setup(x => x.Enabled).Returns(false); + + var payload = new[] { new MemberCacheRefresher.JsonPayload(42, "test", removed: false) }; + var notification = new MemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockMemberService.Verify(x => x.GetById(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.ReIndexForMember(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.DeleteIndexForEntity(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenMemberLookupReturnsNull_ThenReIndexNotCalled() + { + // Arrange + _mockMemberService.Setup(x => x.GetById(It.IsAny())).Returns((IMember?)null); + + var payload = new[] { new MemberCacheRefresher.JsonPayload(42, "test", removed: false) }; + var notification = new MemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.ReIndexForMember(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.DeleteIndexForEntity(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WhenIndexableFieldsUnchanged_ThenReIndexNotCalled() + { + // Arrange + var payload = new[] + { + new MemberCacheRefresher.JsonPayload(42, "test", removed: false, indexableFieldsChanged: false), + }; + var notification = new MemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockMemberService.Verify(x => x.GetById(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.ReIndexForMember(It.IsAny()), Times.Never); + _mockIndexingHandler.Verify(x => x.DeleteIndexForEntity(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void GivenRefreshByPayload_WithMultiplePayloads_ThenEachIsProcessed() + { + // Arrange + var member1 = Mock.Of(); + var member3 = Mock.Of(); + _mockMemberService.Setup(x => x.GetById(1)).Returns(member1); + _mockMemberService.Setup(x => x.GetById(3)).Returns(member3); + + var payload = new[] + { + new MemberCacheRefresher.JsonPayload(1, "one", removed: false), + new MemberCacheRefresher.JsonPayload(2, "two", removed: true), + new MemberCacheRefresher.JsonPayload(3, "three", removed: false), + }; + var notification = new MemberCacheRefresherNotification(payload, MessageType.RefreshByPayload); + + // Act + _sut.Handle(notification); + + // Assert + _mockIndexingHandler.Verify(x => x.ReIndexForMember(member1), Times.Once); + _mockIndexingHandler.Verify(x => x.ReIndexForMember(member3), Times.Once); + _mockIndexingHandler.Verify(x => x.DeleteIndexForEntity(2, false), Times.Once); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberIdentityUserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberIdentityUserTests.cs new file mode 100644 index 000000000000..dc2c6d1557a9 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberIdentityUserTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Text.Json; +using NUnit.Framework; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security; + +[TestFixture] +public class MemberIdentityUserTests +{ + private static readonly JsonSerializerOptions _caseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true }; + + [Test] + public void GetProfileData_With_CaseInsensitive_Options_Returns_Typed_Object() + { + // Arrange + var user = new MemberIdentityUser + { + ProfileData = """{"name":"Test","age":30}""", + }; + + // Act + var result = user.GetProfileData(_caseInsensitiveOptions); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Test", result!.Name); + Assert.AreEqual(30, result.Age); + } + + [Test] + public void GetProfileData_With_Default_Options_Is_Case_Sensitive() + { + // Arrange — lowercase JSON keys don't match PascalCase properties without case-insensitive options. + var user = new MemberIdentityUser + { + ProfileData = """{"name":"Test","age":30}""", + }; + + // Act + var result = user.GetProfileData(); + + // Assert — properties are default because the keys don't match. + Assert.IsNotNull(result); + Assert.IsNull(result!.Name); + Assert.AreEqual(0, result.Age); + } + + [Test] + public void GetProfileData_With_Matching_Case_Works_Without_Options() + { + // Arrange — PascalCase JSON matches PascalCase properties. + var user = new MemberIdentityUser + { + ProfileData = """{"Name":"Test","Age":30}""", + }; + + // Act + var result = user.GetProfileData(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Test", result!.Name); + Assert.AreEqual(30, result.Age); + } + + [Test] + public void GetProfileData_Returns_Null_When_ProfileData_Is_Null() + { + // Arrange + var user = new MemberIdentityUser { ProfileData = null }; + + // Act + var result = user.GetProfileData(); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GetProfileData_Returns_Null_When_ProfileData_Is_Empty() + { + // Arrange + var user = new MemberIdentityUser { ProfileData = string.Empty }; + + // Act + var result = user.GetProfileData(); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GetProfileData_As_Dictionary() + { + // Arrange + var user = new MemberIdentityUser + { + ProfileData = """{"favouriteColor":"Green","homeCity":"London"}""", + }; + + // Act — dictionary keys are case-sensitive strings, so no options needed. + var result = user.GetProfileData>(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Green", result!["favouriteColor"]); + Assert.AreEqual("London", result["homeCity"]); + } + + [Test] + public void ProfileData_Setting_New_Value_Marks_Property_Dirty() + { + // Arrange + var user = new MemberIdentityUser(); + + // Act + user.ProfileData = """{"name":"Test"}"""; + + // Assert — dirty tracking is load-bearing for MemberUserStore.UpdateExternalMemberAsync + // to detect OnExternalLogin callback refreshes and route to the full update path rather + // than the lightweight login path (which would otherwise lose the ProfileData change). + Assert.IsTrue(user.IsPropertyDirty(nameof(MemberIdentityUser.ProfileData))); + } + + [Test] + public void ProfileData_Setting_Same_Value_Does_Not_Mark_Property_Dirty() + { + // Arrange + var user = new MemberIdentityUser(); + user.DisableChangeTracking(); + user.ProfileData = """{"name":"Test"}"""; + user.EnableChangeTracking(); + + // Act — reassigning the exact same value should not flip the dirty flag. + user.ProfileData = """{"name":"Test"}"""; + + // Assert + Assert.IsFalse(user.IsPropertyDirty(nameof(MemberIdentityUser.ProfileData))); + } + + [Test] + public void ProfileData_Setting_With_Change_Tracking_Disabled_Does_Not_Mark_Property_Dirty() + { + // Arrange — MemberUserStore.MapExternalMemberToIdentityUser hydrates ProfileData from + // the store inside a DisableChangeTracking/EnableChangeTracking pair so initial load + // does not appear as a pending change. + var user = new MemberIdentityUser(); + user.DisableChangeTracking(); + + // Act + user.ProfileData = """{"name":"Test"}"""; + + // Assert + Assert.IsFalse(user.IsPropertyDirty(nameof(MemberIdentityUser.ProfileData))); + } + + private class TestProfile + { + public string? Name { get; set; } + + public int Age { get; set; } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs index 27b472233013..028d0fe0672e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs @@ -56,7 +56,8 @@ public MemberManager CreateSut() new IdentityErrorDescriber(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); _mockIdentityOptions = new Mock>(); var idOptions = new IdentityOptions { Lockout = { AllowedForNewUsers = false } }; @@ -274,6 +275,43 @@ public async Task GivenAUserExists_AndIncorrectCurrentPasswordIsProvided_ThenCha Assert.IsNotNull(passwordMismatchError); } + [Test] + public void GivenAnExternalOnlyMember_WhenGeneratePasswordResetToken_ThenThrowsInvalidOperation() + { + // Arrange + var sut = CreateSut(); + var externalUser = new MemberIdentityUser + { + UserName = "external@test.com", + Email = "external@test.com", + IsExternalOnly = true, + }; + + // Act & Assert + Assert.ThrowsAsync( + async () => await sut.GeneratePasswordResetTokenAsync(externalUser)); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenResetPassword_ThenReturnsFailed() + { + // Arrange + var sut = CreateSut(); + var externalUser = new MemberIdentityUser + { + UserName = "external@test.com", + Email = "external@test.com", + IsExternalOnly = true, + }; + + // Act + IdentityResult result = await sut.ResetPasswordAsync(externalUser, "any-token", "newPassword123!"); + + // Assert + Assert.IsFalse(result.Succeeded); + Assert.IsTrue(result.Errors.Any(e => e.Code == "ExternalMemberCannotResetPassword")); + } + private static MemberIdentityUser CreateValidUser() => new(777) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index cdea9af249a1..f78e97e71739 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -1,23 +1,16 @@ -using System.Collections.Generic; -using System.Data; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; using Moq; using NUnit.Framework; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PublishedCache; -using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.UnitTests.TestHelpers; using Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper; -using IScopeProvider = Umbraco.Cms.Infrastructure.Scoping.IScopeProvider; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security; @@ -25,10 +18,12 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security; public class MemberUserStoreTests { private Mock _mockMemberService; + private Mock _mockExternalMemberService; public MemberUserStore CreateSut() { _mockMemberService = new Mock(); + _mockExternalMemberService = new Mock(); var mockScopeProvider = TestHelper.ScopeProvider; return new MemberUserStore( @@ -38,20 +33,21 @@ public MemberUserStore CreateSut() new IdentityErrorDescriber(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + _mockExternalMemberService.Object); } [Test] public async Task GivenISetNormalizedUserName_ThenIShouldGetASuccessResult() { - // arrange + // Arrange var sut = CreateSut(); var fakeUser = new MemberIdentityUser { UserName = "MyName" }; - // act + // Act await sut.SetNormalizedUserNameAsync(fakeUser, "NewName", CancellationToken.None); - // assert + // Assert Assert.AreEqual("NewName", fakeUser.UserName); Assert.AreEqual("NewName", await sut.GetNormalizedUserNameAsync(fakeUser, CancellationToken.None)); } @@ -59,13 +55,13 @@ public async Task GivenISetNormalizedUserName_ThenIShouldGetASuccessResult() [Test] public async Task GivenICreateUser_AndTheUserIsNull_ThenIShouldGetAFailedResultAsync() { - // arrange + // Arrange var sut = CreateSut(); - // act + // Act var actual = await sut.CreateAsync(null); - // assert + // Assert Assert.IsFalse(actual.Succeeded); Assert.IsTrue(actual.Errors.Any(x => x.Code == "IdentityErrorUserStore" && x.Description == "Value cannot be null. (Parameter 'user')")); @@ -75,7 +71,7 @@ public async Task GivenICreateUser_AndTheUserIsNull_ThenIShouldGetAFailedResultA [Test] public async Task GivenICreateUser_AndTheUserDoesNotHaveIdentity_ThenIShouldGetAFailedResultAsync() { - // arrange + // Arrange var sut = CreateSut(); var fakeUser = new MemberIdentityUser(); @@ -93,10 +89,10 @@ public async Task GivenICreateUser_AndTheUserDoesNotHaveIdentity_ThenIShouldGetA .Returns(mockMember); _mockMemberService.Setup(x => x.Save(mockMember, Constants.Security.SuperUserId)); - // act + // Act var actual = await sut.CreateAsync(null); - // assert + // Assert Assert.IsFalse(actual.Succeeded); Assert.IsTrue(actual.Errors.Any(x => x.Code == "IdentityErrorUserStore" && x.Description == "Value cannot be null. (Parameter 'user')")); @@ -106,7 +102,7 @@ public async Task GivenICreateUser_AndTheUserDoesNotHaveIdentity_ThenIShouldGetA [Test] public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShouldGetASuccessResultAsync() { - // arrange + // Arrange var sut = CreateSut(); var fakeUser = new MemberIdentityUser(); @@ -126,10 +122,10 @@ public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShoul _mockMemberService .Setup(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving, Constants.Security.SuperUserId)) .Returns(Attempt.Succeed(null)); - // act + // Act var identityResult = await sut.CreateAsync(fakeUser, CancellationToken.None); - // assert + // Assert Assert.IsTrue(identityResult.Succeeded); Assert.IsTrue(!identityResult.Errors.Any()); _mockMemberService.Verify(x => @@ -140,7 +136,7 @@ public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShoul [Test] public async Task GivenIUpdateAUser_ThenIShouldGetASuccessResultAsync() { - // arrange + // Arrange var sut = CreateSut(); var fakeUser = new MemberIdentityUser { @@ -181,10 +177,10 @@ public async Task GivenIUpdateAUser_ThenIShouldGetASuccessResultAsync() _mockMemberService.Setup(x => x.Save(mockMember, Constants.Security.SuperUserId)); _mockMemberService.Setup(x => x.GetById(123)).Returns(mockMember); - // act + // Act var identityResult = await sut.UpdateAsync(fakeUser, CancellationToken.None); - // assert + // Assert Assert.IsTrue(identityResult.Succeeded); Assert.IsTrue(!identityResult.Errors.Any()); @@ -209,7 +205,7 @@ public async Task GivenIUpdateAUser_ThenIShouldGetASuccessResultAsync() [Test] public async Task GivenIUpdateAUsersLoginPropertiesOnly_ThenIShouldGetASuccessResultAsync() { - // arrange + // Arrange var sut = CreateSut(); var fakeUser = new MemberIdentityUser { @@ -241,10 +237,10 @@ public async Task GivenIUpdateAUsersLoginPropertiesOnly_ThenIShouldGetASuccessRe _mockMemberService.Setup(x => x.UpdateLoginPropertiesAsync(mockMember)); _mockMemberService.Setup(x => x.GetById(123)).Returns(mockMember); - // act + // Act var identityResult = await sut.UpdateAsync(fakeUser, CancellationToken.None); - // assert + // Assert Assert.IsTrue(identityResult.Succeeded); Assert.IsTrue(!identityResult.Errors.Any()); @@ -267,13 +263,13 @@ public async Task GivenIUpdateAUsersLoginPropertiesOnly_ThenIShouldGetASuccessRe [Test] public async Task GivenIDeleteUser_AndTheUserIsNotPresent_ThenIShouldGetAFailedResultAsync() { - // arrange + // Arrange var sut = CreateSut(); - // act + // Act var actual = await sut.DeleteAsync(null); - // assert + // Assert Assert.IsTrue(actual.Succeeded == false); Assert.IsTrue(actual.Errors.Any(x => x.Code == "IdentityErrorUserStore" && x.Description == "Value cannot be null. (Parameter 'user')")); @@ -283,7 +279,7 @@ public async Task GivenIDeleteUser_AndTheUserIsNotPresent_ThenIShouldGetAFailedR [Test] public async Task GivenIDeleteUser_AndTheUserIsDeletedCorrectly_ThenIShouldGetASuccessResultAsync() { - // arrange + // Arrange var memberKey = new Guid("4B003A55-1DE9-4DEB-95A0-352FFC693D8F"); var sut = CreateSut(); var fakeUser = new MemberIdentityUser(777) { Key = memberKey }; @@ -304,14 +300,205 @@ public async Task GivenIDeleteUser_AndTheUserIsDeletedCorrectly_ThenIShouldGetAS _mockMemberService.Setup(x => x.GetById(mockMember.Key)).Returns(mockMember); _mockMemberService.Setup(x => x.Delete(mockMember, Constants.Security.SuperUserId)); - // act + // Act var identityResult = await sut.DeleteAsync(fakeUser, fakeCancellationToken); - // assert + // Assert Assert.IsTrue(identityResult.Succeeded); Assert.IsTrue(!identityResult.Errors.Any()); _mockMemberService.Verify(x => x.GetById(mockMember.Key)); _mockMemberService.Verify(x => x.Delete(mockMember, Constants.Security.SuperUserId)); _mockMemberService.VerifyNoOtherCalls(); } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenFindByEmail_ThenExternalMemberServiceIsUsed() + { + // Arrange + var sut = CreateSut(); + var email = "external@test.com"; + _mockMemberService.Setup(x => x.GetByEmail(email)).Returns((IMember?)null); + + var externalMember = new ExternalMemberIdentity + { + Id = 999, + Key = Guid.NewGuid(), + Email = email, + UserName = email, + Name = "External Test", + IsApproved = true, + CreateDate = DateTime.UtcNow, + SecurityStamp = Guid.NewGuid().ToString(), + }; + _mockExternalMemberService.Setup(x => x.GetByEmailAsync(email)).ReturnsAsync(externalMember); + + // Act + var result = await sut.FindByEmailAsync(email, CancellationToken.None); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result!.IsExternalOnly); + Assert.AreEqual(email, result.Email); + _mockExternalMemberService.Verify(x => x.GetByEmailAsync(email), Times.Once); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenFindByName_ThenExternalMemberServiceIsUsed() + { + // Arrange + var sut = CreateSut(); + var userName = "external@test.com"; + _mockMemberService.Setup(x => x.GetByUsername(userName)).Returns((IMember?)null); + + var externalMember = new ExternalMemberIdentity + { + Id = 999, + Key = Guid.NewGuid(), + Email = userName, + UserName = userName, + Name = "External Test", + IsApproved = true, + CreateDate = DateTime.UtcNow, + SecurityStamp = Guid.NewGuid().ToString(), + }; + _mockExternalMemberService.Setup(x => x.GetByUsernameAsync(userName)).ReturnsAsync(externalMember); + + // Act + var result = await sut.FindByNameAsync(userName, CancellationToken.None); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result!.IsExternalOnly); + Assert.AreEqual(userName, result.UserName); + _mockExternalMemberService.Verify(x => x.GetByUsernameAsync(userName), Times.Once); + } + + [Test] + public async Task GivenAKeyNotInExternalStore_WhenFindByEmail_ThenReturnsNull() + { + // Arrange + var sut = CreateSut(); + var email = "nobody@test.com"; + _mockMemberService.Setup(x => x.GetByEmail(email)).Returns((IMember?)null); + _mockExternalMemberService.Setup(x => x.GetByEmailAsync(email)).ReturnsAsync((ExternalMemberIdentity?)null); + + // Act + var result = await sut.FindByEmailAsync(email, CancellationToken.None); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GivenAnExternalOnlyMember_WhenGetPublishedMember_ThenReturnsNull() + { + // Arrange + var sut = CreateSut(); + var fakeUser = new MemberIdentityUser { IsExternalOnly = true, Key = Guid.NewGuid() }; + + // Act + var result = sut.GetPublishedMember(fakeUser); + + // Assert + Assert.IsNull(result); + _mockMemberService.Verify(x => x.GetById(It.IsAny()), Times.Never); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenCreateAsync_ThenExternalMemberServiceCreateIsCalled() + { + // Arrange + var sut = CreateSut(); + var memberKey = Guid.NewGuid(); + var fakeUser = new MemberIdentityUser + { + UserName = "external@test.com", + Email = "external@test.com", + Name = "External Test", + IsApproved = true, + IsExternalOnly = true, + Key = memberKey, + }; + + var createdIdentity = new ExternalMemberIdentity + { + Id = 999, + Key = memberKey, + Email = fakeUser.Email, + UserName = fakeUser.UserName, + Name = fakeUser.Name, + IsApproved = true, + CreateDate = DateTime.UtcNow, + }; + + _mockExternalMemberService + .Setup(x => x.CreateAsync(It.IsAny(), null)) + .ReturnsAsync(Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, createdIdentity)); + + // Act + var identityResult = await sut.CreateAsync(fakeUser, CancellationToken.None); + + // Assert + Assert.IsTrue(identityResult.Succeeded); + _mockExternalMemberService.Verify(x => x.CreateAsync(It.IsAny(), null), Times.Once); + _mockMemberService.Verify( + x => x.CreateMember(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenDeleteAsync_ThenExternalMemberServiceDeleteIsCalled() + { + // Arrange + var sut = CreateSut(); + var memberKey = Guid.NewGuid(); + var fakeUser = new MemberIdentityUser(999) { IsExternalOnly = true, Key = memberKey }; + + _mockExternalMemberService + .Setup(x => x.DeleteAsync(memberKey)) + .ReturnsAsync(Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, null)); + + // Act + var identityResult = await sut.DeleteAsync(fakeUser, CancellationToken.None); + + // Assert + Assert.IsTrue(identityResult.Succeeded); + _mockExternalMemberService.Verify(x => x.DeleteAsync(memberKey), Times.Once); + _mockMemberService.Verify(x => x.GetById(It.IsAny()), Times.Never); + _mockMemberService.Verify(x => x.Delete(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenUpdateAsync_ThenFullUpdateUsed() + { + // Arrange + var sut = CreateSut(); + var memberKey = Guid.NewGuid(); + var fakeUser = new MemberIdentityUser + { + Id = "999", + Key = memberKey, + UserName = "external@test.com", + Email = "external@test.com", + Name = "External Test", + IsExternalOnly = true, + LastLoginDate = DateTime.UtcNow, + }; + + _mockExternalMemberService + .Setup(x => x.GetByKeyAsync(memberKey)) + .ReturnsAsync(new ExternalMemberIdentity { Id = 1, Key = memberKey, CreateDate = DateTime.UtcNow }); + _mockExternalMemberService + .Setup(x => x.UpdateAsync(It.IsAny())) + .ReturnsAsync(Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, new ExternalMemberIdentity())); + + // Act + var identityResult = await sut.UpdateAsync(fakeUser, CancellationToken.None); + + // Assert — always uses full UpdateAsync, even for login-only changes. + Assert.IsTrue(identityResult.Succeeded); + _mockExternalMemberService.Verify( + x => x.UpdateAsync(It.IsAny()), + Times.Once); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Models/ProfileModelBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Models/ProfileModelBuilderTests.cs new file mode 100644 index 000000000000..b34a8206ea4c --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Models/ProfileModelBuilderTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Website.Models; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Models; + +[TestFixture] +public class ProfileModelBuilderTests +{ + private Mock _mockMemberTypeService = null!; + private Mock _mockMemberService = null!; + private Mock _mockMemberManager = null!; + private Mock _mockHttpContextAccessor = null!; + + [SetUp] + public void SetUp() + { + _mockMemberTypeService = new Mock(); + _mockMemberService = new Mock(); + _mockMemberManager = new Mock(); + _mockHttpContextAccessor = new Mock(); + + var services = new ServiceCollection(); + services.AddSingleton(_mockMemberManager.Object); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + _mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + } + + private ProfileModelBuilder CreateSut() => + new( + _mockMemberTypeService.Object, + _mockMemberService.Object, + Mock.Of(), + _mockHttpContextAccessor.Object); + + [Test] + public async Task GivenNoLoggedInMember_WhenBuildForCurrentMember_ThenReturnsNull() + { + // Arrange + _mockMemberManager + .Setup(x => x.GetUserAsync(It.IsAny())) + .ReturnsAsync((MemberIdentityUser?)null); + + var sut = CreateSut(); + + // Act + var result = await sut.BuildForCurrentMemberAsync(); + + // Assert + Assert.IsNull(result); + } + + [Test] + public async Task GivenALoggedInContentMember_WhenBuildForCurrentMember_ThenReturnsPopulatedModel() + { + // Arrange + var memberKey = Guid.NewGuid(); + var user = new MemberIdentityUser + { + Key = memberKey, + Name = "Test Member", + Email = "test@example.com", + UserName = "test@example.com", + MemberTypeAlias = "Member", + IsApproved = true, + }; + + _mockMemberManager + .Setup(x => x.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var mockMemberType = new Mock(); + mockMemberType.Setup(x => x.PropertyTypes).Returns(Enumerable.Empty()); + _mockMemberTypeService.Setup(x => x.Get("Member")).Returns(mockMemberType.Object); + + var mockMember = new Mock(); + mockMember.Setup(x => x.Key).Returns(memberKey); + _mockMemberService.Setup(x => x.GetById(memberKey)).Returns(mockMember.Object); + + var sut = CreateSut(); + + // Act + var result = await sut.BuildForCurrentMemberAsync(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Test Member", result!.Name); + Assert.AreEqual("test@example.com", result.Email); + Assert.AreEqual(memberKey, result.Key); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenBuildForCurrentMember_ThenReturnsModelWithIdentityFieldsOnly() + { + // Arrange + var memberKey = Guid.NewGuid(); + var user = new MemberIdentityUser + { + Key = memberKey, + Name = "External Member", + Email = "external@example.com", + UserName = "external@example.com", + IsApproved = true, + IsExternalOnly = true, + }; + + _mockMemberManager + .Setup(x => x.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var sut = CreateSut(); + + // Act + var result = await sut.BuildForCurrentMemberAsync(); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("External Member", result!.Name); + Assert.AreEqual("external@example.com", result.Email); + Assert.AreEqual(memberKey, result.Key); + Assert.IsTrue(result.IsApproved); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenBuildForCurrentMember_ThenDoesNotCallMemberService() + { + // Arrange + var user = new MemberIdentityUser + { + Key = Guid.NewGuid(), + Name = "External", + Email = "external@example.com", + UserName = "external@example.com", + IsExternalOnly = true, + }; + + _mockMemberManager + .Setup(x => x.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var sut = CreateSut(); + + // Act + await sut.BuildForCurrentMemberAsync(); + + // Assert — neither IMemberService nor IMemberTypeService should be called. + _mockMemberService.Verify(x => x.GetById(It.IsAny()), Times.Never); + _mockMemberTypeService.Verify(x => x.Get(It.IsAny()), Times.Never); + } + + [Test] + public async Task GivenAnExternalOnlyMember_WhenBuildForCurrentMember_ThenCustomPropertiesAreEmpty() + { + // Arrange + var user = new MemberIdentityUser + { + Key = Guid.NewGuid(), + Name = "External", + Email = "external@example.com", + UserName = "external@example.com", + IsExternalOnly = true, + }; + + _mockMemberManager + .Setup(x => x.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var sut = CreateSut().WithCustomProperties(true); + + // Act + var result = await sut.BuildForCurrentMemberAsync(); + + // Assert — MemberProperties should be null/empty since no content properties exist. + Assert.IsNotNull(result); + Assert.IsTrue(result!.MemberProperties == null || result.MemberProperties.Count == 0); + } +}