diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index e59e0d33..c4b81fe3 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -59,6 +59,7 @@ public partial class App : Application var client = new BlogApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var services = new ServiceCollection(); @@ -87,6 +88,7 @@ public partial class App : Application services.AddSingleton(client); services.AddSingleton(circleClient); services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 23bb4fd9..e4d51a88 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -3,13 +3,11 @@ net10.0 enable latest - true true 1.0.1.0 1.0.1.0 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f 1.0.1-5 - @@ -26,7 +24,6 @@ - @@ -43,4 +40,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs b/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs new file mode 100644 index 00000000..d77f50e7 --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/UserSearchResultDto.cs @@ -0,0 +1,23 @@ +namespace Yavsc.Api.Client.Dtos; + +/// +/// Wire format for GET /api/user-search. +/// +/// Mirrors the server-side +/// Yavsc.Blogs.Controllers.UserSearchResultDto but stops +/// short of any entity navigation properties. Only the fields +/// a client address book needs (id, name, avatar, email) are +/// included. +/// +/// Field names match the JSON the server emits (camelCase +/// via the default policy), so +/// no [JsonPropertyName] attributes are required. +/// +public sealed class UserSearchResultDto +{ + public string Id { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string? FullName { get; set; } + public string? Avatar { get; set; } + public string? Email { get; set; } +} \ No newline at end of file diff --git a/src/Yavsc.Api.Client/UserSearchClient.cs b/src/Yavsc.Api.Client/UserSearchClient.cs new file mode 100644 index 00000000..d1aef6be --- /dev/null +++ b/src/Yavsc.Api.Client/UserSearchClient.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client.Dtos; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for /api/user-search on the Yavsc Blogs +/// server. Used by client-side address books (PostIt.Desktop, +/// future PostIt.Browser CLI, …) to look up Yavsc users by +/// display name or email. +/// +/// The server scopes every endpoint to the authenticated +/// caller; any authenticated user can search the user table of +/// the instance. There is no per-user filtering on the response +/// side — this is by design on single-tenant deployments +/// (closed community). Multi-tenant deployments should gate +/// this controller behind a tenant-scoped policy before +/// exposing it; see the server-side +/// UserSearchApiController doc for details. +/// +public sealed class UserSearchClient +{ + private const string Path = "user-search"; + + private readonly IYavscApiClient _api; + + public UserSearchClient(IYavscApiClient api, string blogsBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); + + if (api.Http.BaseAddress is null) + api.Http.BaseAddress = new Uri(blogsBaseAddress); + } + + /// + /// Search users by display name (substring) or email (exact). + /// + /// Substring filter on FullName or + /// UserName. Empty or null returns an empty list (the server + /// would return all users, which we don't want by + /// default). + /// Optional exact-match filter on + /// Email. + /// Maximum results, capped at 100. + /// Default 25. + public Task> SearchAsync( + string? query = null, + string? email = null, + int take = 25, + CancellationToken ct = default) + { + // Match the server's contract: at least one filter is + // expected. The server doesn't enforce this (an empty + // query + empty email returns the first `take` users + // alphabetically), but the address-book UX is "type + // something to search", so we short-circuit empty + // queries client-side. + if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email)) + return Task.FromResult(new List()); + + var qs = new List(); + if (!string.IsNullOrWhiteSpace(query)) + qs.Add($"q={Uri.EscapeDataString(query)}"); + if (!string.IsNullOrWhiteSpace(email)) + qs.Add($"e={Uri.EscapeDataString(email)}"); + qs.Add($"take={Math.Clamp(take, 1, 100)}"); + + return _api.CallAsync>( + HttpMethod.Get, + $"{Path}?{string.Join('&', qs)}", + ct: ct); + } +} \ No newline at end of file