From 343538a7eac4bf6da158e1725bad02ab31fc9929 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 4 Sep 2026 20:25:06 +0100 Subject: [PATCH 1/2] setup the avatar --- src/PostIt/PostIt.Tests/BearerScopeTests.cs | 4 +- src/PostIt/PostIt.Tests/PostAclDialogTests.cs | 6 +- src/PostIt/PostIt.Tests/SettingsLoadTests.cs | 2 +- .../PostIt.Tests/YavscApiClientTests.cs | 8 +- .../Helpers/ServiceCollectionHelpers.cs | 4 +- src/PostIt/PostIt/Services/YavscApiClient.cs | 51 +++++++++ .../ViewModels/Commands/BrushViewModel.cs | 2 +- .../PostIt/ViewModels/Settings/Settings.cs | 12 +- .../PostIt/Views/Commands/BrushPage.axaml | 41 +------ .../PostIt/Views/Commands/RdvPage.axaml | 12 +- src/PostIt/PostIt/Views/SettingsPage.axaml | 33 +++--- src/PostIt/PostIt/Views/SettingsPage.axaml.cs | 60 +++++++++- .../accounting/AccountController.cs | 106 ++++++++++++++++-- src/Yavsc.Org/Views/Manage/SetAvatar.cshtml | 49 ++++++-- 14 files changed, 299 insertions(+), 91 deletions(-) diff --git a/src/PostIt/PostIt.Tests/BearerScopeTests.cs b/src/PostIt/PostIt.Tests/BearerScopeTests.cs index c6bf7d56..984483fc 100644 --- a/src/PostIt/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt/PostIt.Tests/BearerScopeTests.cs @@ -67,7 +67,7 @@ public class BearerScopeTests Scopes = userScopes, RedirectUri = "postit://callback", }, - BusinessApiUrl = "https://example.invalid/api/v1/", + ApiUrl = "https://example.invalid/api/v1/", }; var tokensPath = Path.Combine( @@ -266,7 +266,7 @@ public class BearerScopeTests // private HttpClient is independent, so we resolve the // absolute URI ourselves from Settings.BusinessApiUrl — // the same URL BlogApiClient would have set as BaseAddress. - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); + var absolute = new Uri(new Uri(Settings.ApiUrl), path); using var req = new HttpRequestMessage(method, absolute); req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index dd277629..e7175203 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -95,7 +95,7 @@ public class PostAclDialogTests HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); + var absolute = new Uri(new Uri(Settings.ApiUrl), path); using var req = new HttpRequestMessage(method, absolute); using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); resp.EnsureSuccessStatusCode(); @@ -123,8 +123,8 @@ public class PostAclDialogTests var handler = new CountingHttpHandler(); var settings = new Settings(); var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler); - var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl); - var circleClient = new CircleApiClient(api, settings.BusinessApiUrl); + var aclClient = new BlogAclApiClient(api, settings.ApiUrl); + var circleClient = new CircleApiClient(api, settings.ApiUrl); var services = new ServiceCollection(); services.AddSingleton(settings); diff --git a/src/PostIt/PostIt.Tests/SettingsLoadTests.cs b/src/PostIt/PostIt.Tests/SettingsLoadTests.cs index 3f350240..3f8dc02b 100644 --- a/src/PostIt/PostIt.Tests/SettingsLoadTests.cs +++ b/src/PostIt/PostIt.Tests/SettingsLoadTests.cs @@ -89,7 +89,7 @@ public class SettingsLoadTests settings.Authentication.RedirectUri = global::AuthenticationSettings.DesktopRedirectUri; - settings.BusinessApiUrl = flip + settings.ApiUrl = flip ? "https://a.example.test/api/v1/" : "https://b.example.test/api/v1/"; diff --git a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs index 21c0dd00..b074de66 100644 --- a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs @@ -58,7 +58,7 @@ public class YavscApiClientTests // calls CallAsync("posts", ...) directly (bypassing // BlogApiClient, which is the only thing that would set // it in production). Mirror prod here. - reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl); + reloaded.Http.BaseAddress = new Uri(settings.ApiUrl); var posts = await reloaded.CallAsync>( HttpMethod.Get, "posts", TestContext.Current.CancellationToken); @@ -118,7 +118,7 @@ public class YavscApiClientTests RedirectUri = "postit://callback", Scopes = new[] { "openid" }, }, - BusinessApiUrl = "https://127.0.0.1:5003/api/v1", + ApiUrl = "https://127.0.0.1:5003/api/v1", }; var client = new YavscApiClient(settings, new TokenStore(Path.Combine( Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); @@ -162,7 +162,7 @@ public class YavscApiClientTests RedirectUri = authority.LoopbackRedirectUri, Scopes = new[] { "openid", "profile", "blog" } }, - BusinessApiUrl = apiBaseUrl + ApiUrl = apiBaseUrl }; private static async Task LoginAndPersistAsync( @@ -175,7 +175,7 @@ public class YavscApiClientTests // directly (bypassing BlogApiClient) rely on the same // BaseAddress the production chain sets in BlogApiClient's // ctor. Mirror that here so "posts" resolves to the stub. - client.Http.BaseAddress = new Uri(settings.BusinessApiUrl); + client.Http.BaseAddress = new Uri(settings.ApiUrl); // Force the API client to use the test browser by routing the // LoginInteractiveAsync call through a small wrapper. diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index ff532b2b..1b71e873 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl); - var billingClient = new BillingApiClient(api, settings.BusinessApiUrl); + var activityClient = new ActivityApiClient(api, settings.ApiUrl); + var billingClient = new BillingApiClient(api, settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); // Vues diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 726b3f4f..f08bcc08 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -351,6 +352,56 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable _store.Save(_tokens); } + /// + /// Upload a user avatar to the Yavsc API. The server expects a + /// single multipart file named file and validates the image + /// content type before persisting it. + /// + public async Task SetAvatarAsync( + Stream imageStream, + string fileName, + string? contentType = null, + CancellationToken ct = default) + { + if (imageStream is null) + throw new ArgumentNullException(nameof(imageStream)); + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("A file name is required.", nameof(fileName)); + + var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar"); + + await EnsureFreshTokenAsync(ct).ConfigureAwait(false); + + var attemptUpload = async () => + { + if (imageStream.CanSeek) + imageStream.Position = 0; + + using var content = new MultipartFormDataContent(); + using var fileContent = new StreamContent(imageStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue( + string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType); + content.Add(fileContent, "file", fileName); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = content, + }; + + return await Http.SendAsync(request, ct).ConfigureAwait(false); + }; + + var response = await attemptUpload().ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response.Dispose(); + await ForceRefreshAsync(ct).ConfigureAwait(false); + response = await attemptUpload().ConfigureAwait(false); + } + + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + } + public async Task LogoutAsync() { _store.Clear(); diff --git a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs index 03dab60e..d49fde80 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using Yavsc.Abstract.Workflow; using Yavsc.Api.Client; +using Yavsc.Models.Billing; using Yavsc.Models.Haircut; namespace PostIt.ViewModels.Commands; @@ -21,7 +22,6 @@ public partial class BrushViewModel : RdvViewModel [ObservableProperty] public partial HairPrestationDto? SelectedPrestation { get; set; } - public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) : base(activity, performer, form, billingClient) { diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 0c7a040b..e63f0b32 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; [ObservableProperty] - public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; + public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; [ObservableProperty] public partial string SearchText { get; set; } = string.Empty; @@ -45,7 +45,7 @@ public partial class Settings : ViewModelBase partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); - partial void OnBusinessApiUrlChanged(string value) => MarkDirty(); + partial void OnApiUrlChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty(); /// @@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl) ? settings.BlogsApiUrl : legacyApiUrl ?? this.BlogsApiUrl; - this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl) - ? settings.BusinessApiUrl - : this.BusinessApiUrl; + this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl) + ? settings.ApiUrl + : this.ApiUrl; this.SearchText = settings.SearchText ?? string.Empty; if (!(settings.Authentication is null)) { @@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase }; this.DarkMode = false; this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/"; - this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/"; + this.ApiUrl = "https://api.pschneider.fr/api/v1/"; this.SearchText = string.Empty; } diff --git a/src/PostIt/PostIt/Views/Commands/BrushPage.axaml b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml index 75397abd..9c33cecb 100644 --- a/src/PostIt/PostIt/Views/Commands/BrushPage.axaml +++ b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml @@ -33,13 +33,11 @@ + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> @@ -60,14 +58,12 @@ + Margin="0,0,12,8" /> + Margin="0,0,0,8"> @@ -78,39 +74,14 @@ - - - - - - - - - - - - - - + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> @@ -57,13 +55,11 @@ + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> - + + Text="{Binding ApiUrl, Mode=TwoWay}"/> - - - public async Task SetAvatarAsync( + public async Task SetAvatarAsync( Stream imageStream, string fileName, string? contentType = null, @@ -400,6 +400,27 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable } await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + + var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(payload)) + return "Avatar mis à jour."; + + try + { + using var json = JsonDocument.Parse(payload); + if (json.RootElement.TryGetProperty("message", out var msgEl)) + { + var message = msgEl.GetString(); + if (!string.IsNullOrWhiteSpace(message)) + return message; + } + } + catch (JsonException) + { + // Keep a user-friendly fallback when the API payload is not JSON. + } + + return "Avatar mis à jour."; } public async Task LogoutAsync() diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml index 3c6d7410..611b8995 100644 --- a/src/PostIt/PostIt/Views/SettingsPage.axaml +++ b/src/PostIt/PostIt/Views/SettingsPage.axaml @@ -46,8 +46,18 @@ - -