From 026b8294deb71120c8385fef4ff2bc34228a2dfe Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 7 Apr 2026 12:39:22 +0200 Subject: [PATCH] feat(07-03): implement GraphUserSearchService for people-picker autocomplete - Queries Graph /users with startsWith filter on displayName, mail, UPN - Requires minimum 2 chars to prevent overly broad queries - Sets ConsistencyLevel=eventual + Count=true (required for advanced filter) - Escapes single quotes to prevent OData injection - Returns up to maxResults (default 10) GraphUserResult records --- .../Services/GraphUserSearchService.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 SharepointToolbox/Services/GraphUserSearchService.cs diff --git a/SharepointToolbox/Services/GraphUserSearchService.cs b/SharepointToolbox/Services/GraphUserSearchService.cs new file mode 100644 index 0000000..3af13ec --- /dev/null +++ b/SharepointToolbox/Services/GraphUserSearchService.cs @@ -0,0 +1,53 @@ +using SharepointToolbox.Infrastructure.Auth; + +namespace SharepointToolbox.Services; + +/// +/// Searches tenant users via Microsoft Graph API. +/// Used by the people-picker autocomplete in the User Access Audit tab. +/// +public class GraphUserSearchService : IGraphUserSearchService +{ + private readonly GraphClientFactory _graphClientFactory; + + public GraphUserSearchService(GraphClientFactory graphClientFactory) + { + _graphClientFactory = graphClientFactory; + } + + public async Task> SearchUsersAsync( + string clientId, + string query, + int maxResults = 10, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query) || query.Length < 2) + return Array.Empty(); + + var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct); + + // Use $filter with startsWith on displayName and mail. + // Graph API requires ConsistencyLevel=eventual for advanced queries. + var escapedQuery = query.Replace("'", "''"); + var response = await graphClient.Users.GetAsync(config => + { + config.QueryParameters.Filter = + $"startsWith(displayName,'{escapedQuery}') or startsWith(mail,'{escapedQuery}') or startsWith(userPrincipalName,'{escapedQuery}')"; + config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail" }; + config.QueryParameters.Top = maxResults; + config.QueryParameters.Orderby = new[] { "displayName" }; + config.Headers.Add("ConsistencyLevel", "eventual"); + config.QueryParameters.Count = true; + }, ct); + + if (response?.Value is null) + return Array.Empty(); + + return response.Value + .Select(u => new GraphUserResult( + DisplayName: u.DisplayName ?? u.UserPrincipalName ?? "Unknown", + UserPrincipalName: u.UserPrincipalName ?? string.Empty, + Mail: u.Mail)) + .ToList(); + } +}