diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs index 16f01c5e9..21d532cd1 100644 --- a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs +++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs @@ -1,3 +1,4 @@ +using Yavsc.Abstract.Identity; using PostIt.ViewModels; namespace PostIt.Tests; @@ -12,4 +13,157 @@ public class HomePageProviderFlowTests Assert.NotNull(vm.OpenProviderRequests); Assert.True(vm.OpenProviderRequests.CanExecute(null)); } + + [Fact] + public void HomePage_exposes_profile_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenProfile); + Assert.True(vm.OpenProfile.CanExecute(null)); + } + + [Fact] + public void HomePage_exposes_performer_configuration_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenPerformerConfiguration); + Assert.True(vm.OpenPerformerConfiguration.CanExecute(null)); + } + + [Fact] + public void HomePage_exposes_administration_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenAdministration); + Assert.True(vm.OpenAdministration.CanExecute(null)); + } + + [Fact] + public void Administration_page_filters_users_by_search_text() + { + var vm = new AdministrationPageViewModel(); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" }); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" }); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol", FullName = "Carol Smith" }); + + vm.SearchText = "ali"; + + Assert.Single(vm.FilteredUsers); + Assert.Equal("alice", vm.FilteredUsers[0].UserName); + } + + [Fact] + public void Administration_page_filters_users_by_full_name() + { + var vm = new AdministrationPageViewModel(); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" }); + vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" }); + + vm.SearchText = "example"; + + Assert.Single(vm.FilteredUsers); + Assert.Equal("alice", vm.FilteredUsers[0].UserName); + } + + [Fact] + public void UserProfilePage_exposes_full_name_and_address_fields() + { + var vm = new UserProfilePageViewModel(); + + Assert.Equal("—", vm.FullName); + Assert.Equal("—", vm.Address); + Assert.Equal("Non configuré", vm.DedicatedGoogleCalendar); + Assert.Equal("Aucune information bancaire", vm.BankInfoSummary); + Assert.NotNull(vm.RefreshCommand); + } + + [Fact] + public void ProfileUpdateRequest_allows_username_updates() + { + var request = new ProfileUpdateRequest + { + UserName = "alice.new" + }; + + Assert.Equal("alice.new", request.UserName); + } + + [Fact] + public void UserProfilePage_can_save_profile_values() + { + var vm = new UserProfilePageViewModel + { + UserName = "alice.new", + FullName = "Alice Example", + Address = "14 rue de l’Érable", + DedicatedGoogleCalendar = "calendar-123", + BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606" + }; + + Assert.NotNull(vm.SaveProfileCommand); + Assert.False(vm.SaveProfileCommand.CanExecute(null)); + } + + [Fact] + public void UserProfilePage_builds_profile_payload_for_calendar_and_bank_values() + { + var vm = new UserProfilePageViewModel + { + UserName = "alice.new", + FullName = "Alice Example", + Address = "14 rue de l’Érable", + DedicatedGoogleCalendar = "calendar-123", + BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606" + }; + + var payload = vm.BuildProfileUpdateRequest(); + + Assert.Equal("alice.new", payload.UserName); + Assert.Equal("calendar-123", payload.GoogleCalendarId); + Assert.Null(payload.BankInfoSummary); + } + + [Fact] + public void UserProfilePage_turns_placeholder_values_into_empty_payload_fields() + { + var vm = new UserProfilePageViewModel + { + UserName = "alice.new", + FullName = "Alice Example", + Address = "14 rue de l’Érable", + DedicatedGoogleCalendar = "Non configuré", + BankInfoSummary = "Aucune information bancaire" + }; + + var payload = vm.BuildProfileUpdateRequest(); + + Assert.Equal(string.Empty, payload.GoogleCalendarId); + Assert.Null(payload.BankInfoSummary); + } + + [Fact] + public void PerformerConfigurationPage_preserves_current_user_id_in_save_payload() + { + var vm = new PerformerConfigurationPageViewModel + { + PerformerId = "user-42", + UserName = "alice", + ExerciseCountryCode = "fr", + SIREN = "123456789", + Website = "https://example.com", + Active = true, + AcceptNotifications = true, + AcceptPublicContact = true, + UseGeoLocalizationToReduceDistanceWithClients = true + }; + + var payload = vm.BuildPerformerProfileSettings(); + + Assert.Equal("user-42", payload.PerformerId); + Assert.Equal("alice", payload.UserName); + Assert.Equal("fr", payload.ExerciseCountryCode); + } } diff --git a/src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs b/src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs new file mode 100644 index 000000000..407183b77 --- /dev/null +++ b/src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs @@ -0,0 +1,117 @@ +using System.Net.Http; +using System.Text.Json; +using Yavsc.Abstract.Resources; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class ResourceUsageApiClientTests +{ + [Fact] + public async Task GetCurrentAsync_calls_user_usage_endpoint() + { + var fake = new FakeYavscApiClient(); + var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/"); + + var usage = await client.GetCurrentAsync(); + + Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath); + Assert.Equal("GET", fake.LastMethod); + Assert.Equal(12.5m, usage.ApiCalls); + Assert.Equal(3.2m, usage.CpuSeconds); + } + + [Fact] + public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint() + { + var fake = new FakeYavscApiClient(); + var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/"); + + var usage = await client.GetAdminOverviewAsync(); + + Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath); + Assert.Equal("GET", fake.LastMethod); + Assert.Equal(2, usage.Count); + Assert.Equal("administrator", usage[0].UserName); + } + + private sealed class FakeYavscApiClient : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public string? LastPath { get; private set; } + public string? LastMethod { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method.Method; + LastPath = path; + + if (typeof(T) == typeof(ResourceUsageSummary)) + { + var summary = new ResourceUsageSummary + { + UserId = "user-42", + UserName = "alice", + ApiCalls = 12.5m, + CpuSeconds = 3.2m, + BandwidthMb = 18.4m, + StorageMb = 51.2m, + EstimatedCompensation = 4.9m, + Currency = "EUR", + RecordedAtUtc = DateTimeOffset.UtcNow + }; + + return Task.FromResult((T)(object)summary); + } + + if (typeof(T) == typeof(List)) + { + var list = new List + { + new() + { + UserId = "admin-user", + UserName = "administrator", + ApiCalls = 90m, + CpuSeconds = 14m, + BandwidthMb = 500m, + StorageMb = 2048m, + EstimatedCompensation = 32m, + Currency = "EUR", + RecordedAtUtc = DateTimeOffset.UtcNow + }, + new() + { + UserId = "user-99", + UserName = "bob", + ApiCalls = 40m, + CpuSeconds = 7m, + BandwidthMb = 120m, + StorageMb = 600m, + EstimatedCompensation = 15m, + Currency = "EUR", + RecordedAtUtc = DateTimeOffset.UtcNow + } + }; + + return Task.FromResult((T)(object)list); + } + + throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}"); + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => throw new NotSupportedException(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => Task.CompletedTask; + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => Task.CompletedTask; + + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + => Task.FromResult(Array.Empty()); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 3d135987b..22885aca8 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -76,6 +76,9 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // ViewModels @@ -95,6 +98,9 @@ public static class ServiceCollectionHelpers services.AddSingleton(userDirectory); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 01ecb0c25..986ddc92f 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -60,6 +60,9 @@ public class ViewLocator : IDataTemplate EstimateListPageViewModel => services.GetRequiredService(), EstimateValidationPageViewModel => services.GetRequiredService(), MyFilesViewModel => services.GetRequiredService(), + UserProfilePageViewModel => services.GetRequiredService(), + PerformerConfigurationPageViewModel => services.GetRequiredService(), + AdministrationPageViewModel => services.GetRequiredService(), ResourceUsagePageViewModel => services.GetRequiredService(), ChatViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, diff --git a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs new file mode 100644 index 000000000..7ef626c56 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Identity; + +namespace PostIt.ViewModels; + +public partial class AdministrationPageViewModel : ViewModelBase +{ + private readonly YavscApiClient? _api; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial ObservableCollection Users { get; set; } = new(); + + [ObservableProperty] + public partial string SearchText { get; set; } = string.Empty; + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Chargement…"; + + public IReadOnlyList FilteredUsers => + string.IsNullOrWhiteSpace(SearchText) + ? Users.ToList() + : Users.Where(user => + (user.UserName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (user.FullName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (user.Email ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || (user.UserId ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public AdministrationPageViewModel(YavscApiClient? api) + { + _api = api; + } + + public AdministrationPageViewModel() : this(null) { } + + public Task InitializeAsync() => RefreshAsync(); + + partial void OnSearchTextChanged(string value) + { + OnPropertyChanged(nameof(FilteredUsers)); + } + + [RelayCommand] + public async Task RefreshAsync() + { + if (_api is null) + { + StatusMessage = "Client API d’administration indisponible."; + return; + } + + IsBusy = true; + try + { + var query = SearchText.Trim(); + var usersEndpoint = string.IsNullOrWhiteSpace(query) + ? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString() + : new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString(); + + var users = await _api.CallAsync>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true); + + Users.Clear(); + foreach (var user in users ?? []) + { + Users.Add(user); + } + + OnPropertyChanged(nameof(FilteredUsers)); + StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé."; + } + catch (Exception ex) + { + Users.Clear(); + OnPropertyChanged(nameof(FilteredUsers)); + StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 56a3979e0..6a29afbc6 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -36,6 +36,9 @@ public class HomePageViewModel : ViewModelBase OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); + OpenProfile = new AsyncRelayCommand(OpenProfileAsync); + OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync); + OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync); OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync); OpenChat = new AsyncRelayCommand(OpenChatAsync); } @@ -43,6 +46,9 @@ public class HomePageViewModel : ViewModelBase public IAsyncRelayCommand OpenChat { get; } public IAsyncRelayCommand OpenBlogs { get; } + public IAsyncRelayCommand OpenProfile { get; } + public IAsyncRelayCommand OpenPerformerConfiguration { get; } + public IAsyncRelayCommand OpenAdministration { get; } public IAsyncRelayCommand OpenResourceUsage { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } @@ -140,6 +146,60 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private async Task OpenProfileAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = app.ServiceProvider?.GetRequiredService(); + if (vm is null) + { + throw new InvalidOperationException("Page profil indisponible."); + } + + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + + private async Task OpenPerformerConfigurationAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = app.ServiceProvider?.GetRequiredService(); + if (vm is null) + { + throw new InvalidOperationException("Page performer indisponible."); + } + + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + + private async Task OpenAdministrationAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = app.ServiceProvider?.GetRequiredService(); + if (vm is null) + { + throw new InvalidOperationException("Page administration indisponible."); + } + + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + private async Task OpenResourceUsageAsync() { var app = (App?)Application.Current; diff --git a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs new file mode 100644 index 000000000..ed70665f0 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs @@ -0,0 +1,612 @@ +using System; +using System.Collections.ObjectModel; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Identity; + +namespace PostIt.ViewModels; + +public partial class UserProfilePageViewModel : ViewModelBase +{ + private readonly YavscApiClient? _api; + private readonly SessionStatusViewModel? _sessionStatus; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))] + [NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))] + [NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))] + [NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))] + [NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))] + [NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))] + [NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))] + [NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))] + [NotifyCanExecuteChangedFor(nameof(SetMonthlyEmailPreferenceCommand))] + [NotifyCanExecuteChangedFor(nameof(SetTwoFactorCommand))] + [NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))] + [NotifyCanExecuteChangedFor(nameof(AddBankAccountCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteBankAccountCommand))] + [NotifyCanExecuteChangedFor(nameof(RemoveExternalLoginCommand))] + [NotifyCanExecuteChangedFor(nameof(SendEmailConfirmationCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadCalendarsCommand))] + [NotifyCanExecuteChangedFor(nameof(ChooseCalendarCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteAccountCommand))] + public partial bool IsProfileLoaded { get; set; } + + [ObservableProperty] + public partial string UserName { get; set; } = "—"; + + [ObservableProperty] + public partial string CurrentUserName { get; set; } = "—"; + + [ObservableProperty] + public partial string UserId { get; set; } = "—"; + + [ObservableProperty] + public partial string FullName { get; set; } = "—"; + + [ObservableProperty] + public partial string Email { get; set; } = "—"; + + [ObservableProperty] + public partial string Address { get; set; } = "—"; + + [ObservableProperty] + public partial string Avatar { get; set; } = "—"; + + [ObservableProperty] + public partial string Roles { get; set; } = "—"; + + [ObservableProperty] + public partial string DedicatedGoogleCalendar { get; set; } = "Non configuré"; + + [ObservableProperty] + public partial string BankInfoSummary { get; set; } = "Aucune information bancaire"; + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial bool EmailConfirmed { get; set; } + + [ObservableProperty] + public partial bool AllowMonthlyEmail { get; set; } + + [ObservableProperty] + public partial bool HasPassword { get; set; } + + [ObservableProperty] + public partial bool TwoFactorEnabled { get; set; } + + [ObservableProperty] + public partial int ExternalLoginCount { get; set; } + + [ObservableProperty] + public partial long PostsCounter { get; set; } + + [ObservableProperty] + public partial long DiskUsage { get; set; } + + [ObservableProperty] + public partial long DiskQuota { get; set; } + + [ObservableProperty] + public partial decimal Credits { get; set; } + + [ObservableProperty] + public partial string CurrentPassword { get; set; } = string.Empty; + + [ObservableProperty] + public partial string NewPassword { get; set; } = string.Empty; + + [ObservableProperty] + public partial string ConfirmPassword { get; set; } = string.Empty; + + [ObservableProperty] + public partial string BankIban { get; set; } = string.Empty; + + [ObservableProperty] + public partial string BankBic { get; set; } = string.Empty; + + [ObservableProperty] + public partial string BankCode { get; set; } = string.Empty; + + [ObservableProperty] + public partial string BankWicketCode { get; set; } = string.Empty; + + [ObservableProperty] + public partial string BankAccountNumber { get; set; } = string.Empty; + + [ObservableProperty] + public partial string UsernameConfirmation { get; set; } = string.Empty; + + [ObservableProperty] + public partial int BankedKey { get; set; } + + public ObservableCollection BankAccounts { get; } = []; + public ObservableCollection ExternalLogins { get; } = []; + public ObservableCollection Calendars { get; } = []; + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null) + { + _api = api; + _sessionStatus = sessionStatus; + } + + public UserProfilePageViewModel() : this(null, null) { } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + if (_api is null) + { + IsProfileLoaded = false; + StatusMessage = "Client API de profil indisponible."; + return; + } + + IsBusy = true; + IsProfileLoaded = false; + try + { + var me = await _api.CallAsync(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true); + if (me is null) + { + throw new InvalidOperationException("Le serveur a renvoyé un profil vide."); + } + + UserName = me.UserName ?? "—"; + CurrentUserName = UserName; + UserId = me.UserId; + FullName = me.FullName ?? "—"; + Email = me.Email ?? "—"; + Address = me.Address ?? "—"; + Avatar = string.IsNullOrWhiteSpace(me.Avatar) ? "Aucun avatar" : me.Avatar; + Roles = me.Roles is { Length: > 0 } ? string.Join(", ", me.Roles) : "Aucun rôle"; + DedicatedGoogleCalendar = string.IsNullOrWhiteSpace(me.DedicatedGoogleCalendar) ? "Non configuré" : me.DedicatedGoogleCalendar; + BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary; + EmailConfirmed = me.EmailConfirmed; + AllowMonthlyEmail = me.AllowMonthlyEmail; + HasPassword = me.HasPassword; + TwoFactorEnabled = me.TwoFactorEnabled; + ExternalLoginCount = me.ExternalLoginCount; + PostsCounter = me.PostsCounter; + DiskUsage = me.DiskUsage; + DiskQuota = me.DiskQuota; + Credits = me.Credits; + BankAccounts.Clear(); + foreach (var account in me.BankAccounts) + { + BankAccounts.Add(account); + } + ExternalLogins.Clear(); + foreach (var login in me.ExternalLogins) + { + ExternalLogins.Add(login); + } + IsProfileLoaded = true; + StatusMessage = "Profil chargé."; + } + catch (Exception ex) + { + IsProfileLoaded = false; + UserName = "—"; + CurrentUserName = "—"; + UserId = "—"; + FullName = "—"; + Email = "—"; + Address = "—"; + Avatar = "—"; + Roles = "—"; + DedicatedGoogleCalendar = "Non configuré"; + BankInfoSummary = "Aucune information bancaire"; + EmailConfirmed = false; + AllowMonthlyEmail = false; + HasPassword = false; + TwoFactorEnabled = false; + ExternalLoginCount = 0; + PostsCounter = 0; + DiskUsage = 0; + DiskQuota = 0; + Credits = 0; + BankAccounts.Clear(); + ExternalLogins.Clear(); + Calendars.Clear(); + StatusMessage = $"Impossible de charger le profil : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + public ProfileUpdateRequest BuildProfileUpdateRequest() + { + static string NormalizeField(string? value, string placeholder) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var trimmed = value.Trim(); + return string.Equals(trimmed, placeholder, StringComparison.OrdinalIgnoreCase) + ? string.Empty + : trimmed; + } + + return new ProfileUpdateRequest + { + UserName = NormalizeField(UserName, "—"), + FullName = NormalizeField(FullName, "—"), + Address = NormalizeField(Address, "—"), + GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré") + }; + } + + private bool CanSaveProfile() => _api is not null && IsProfileLoaded && !IsBusy; + + [RelayCommand(CanExecute = nameof(CanSaveProfile))] + public async Task SaveProfileAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + IsBusy = true; + try + { + var payload = BuildProfileUpdateRequest(); + + var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString(); + await _api.CallAsync(HttpMethod.Put, endpoint, payload).ConfigureAwait(true); + StatusMessage = "Profil enregistré."; + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + StatusMessage = $"Impossible d’enregistrer le profil : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task SetMonthlyEmailPreferenceAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + await UpdateBooleanPreferenceAsync( + "account/preferences/monthly-email", + new MonthlyEmailPreferenceRequest { Enabled = AllowMonthlyEmail }, + enabled => AllowMonthlyEmail = enabled, + "préférence de courriel"); + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task SetTwoFactorAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + await UpdateBooleanPreferenceAsync( + "account/security/two-factor", + new TwoFactorRequest { Enabled = TwoFactorEnabled }, + enabled => TwoFactorEnabled = enabled, + "authentification à deux facteurs"); + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task ChangePasswordAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + if (string.IsNullOrWhiteSpace(NewPassword) + || !string.Equals(NewPassword, ConfirmPassword, StringComparison.Ordinal)) + { + StatusMessage = "Le nouveau mot de passe et sa confirmation doivent être identiques."; + return; + } + + IsBusy = true; + try + { + var endpoint = BuildEndpoint("account/security/password"); + await _api.CallAsync(HttpMethod.Put, endpoint, new ChangePasswordRequest + { + CurrentPassword = CurrentPassword, + NewPassword = NewPassword, + ConfirmPassword = ConfirmPassword + }).ConfigureAwait(true); + CurrentPassword = string.Empty; + NewPassword = string.Empty; + ConfirmPassword = string.Empty; + HasPassword = true; + StatusMessage = "Mot de passe mis à jour."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de modifier le mot de passe : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task AddBankAccountAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + if (string.IsNullOrWhiteSpace(BankIban) && string.IsNullOrWhiteSpace(BankBic)) + { + StatusMessage = "Renseignez un IBAN ou un BIC."; + return; + } + + IsBusy = true; + try + { + var endpoint = BuildEndpoint("account/bank-info"); + await _api.CallAsync(HttpMethod.Post, endpoint, new BankAccountInfoRequest + { + IBAN = BankIban.Trim(), + BIC = BankBic.Trim(), + BankCode = BankCode.Trim(), + WicketCode = BankWicketCode.Trim(), + AccountNumber = BankAccountNumber.Trim(), + BankedKey = BankedKey + }).ConfigureAwait(true); + BankIban = string.Empty; + BankBic = string.Empty; + BankCode = string.Empty; + BankWicketCode = string.Empty; + BankAccountNumber = string.Empty; + BankedKey = 0; + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + StatusMessage = $"Impossible d’ajouter le compte bancaire : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task DeleteBankAccountAsync(BankAccountInfo? account) + { + if (_api is null || account is null) + { + return; + } + + IsBusy = true; + try + { + await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/bank-info/{account.Id}")).ConfigureAwait(true); + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + StatusMessage = $"Impossible de supprimer le compte bancaire : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task RemoveExternalLoginAsync(LinkedExternalLogin? login) + { + if (_api is null || login is null) + { + return; + } + + IsBusy = true; + try + { + var query = $"provider={Uri.EscapeDataString(login.Provider)}&providerKey={Uri.EscapeDataString(login.ProviderKey)}"; + await _api.CallAsync(HttpMethod.Delete, BuildEndpoint($"account/security/external-logins?{query}")).ConfigureAwait(true); + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + StatusMessage = $"Impossible de supprimer cette connexion externe : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task SendEmailConfirmationAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + IsBusy = true; + try + { + await _api.CallAsync(HttpMethod.Post, BuildEndpoint("account/email/confirmation")).ConfigureAwait(true); + StatusMessage = "E-mail de confirmation envoyé."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible d’envoyer l’e-mail de confirmation : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task LoadCalendarsAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + IsBusy = true; + try + { + var calendars = await _api.CallAsync( + HttpMethod.Get, + BuildEndpoint("account/calendars")).ConfigureAwait(true); + Calendars.Clear(); + foreach (var calendar in calendars ?? []) + { + Calendars.Add(calendar); + } + + StatusMessage = Calendars.Count == 0 + ? "Aucun agenda Google dont vous êtes propriétaire n’est disponible." + : "Agendas Google chargés."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de charger les agendas Google : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task ChooseCalendarAsync(CalendarOption? calendar) + { + if (calendar is null) + { + DedicatedGoogleCalendar = "Non configuré"; + } + else + { + DedicatedGoogleCalendar = calendar.Id; + } + + await SaveProfileAsync().ConfigureAwait(true); + } + + [RelayCommand(CanExecute = nameof(CanModifyAccount))] + public async Task DeleteAccountAsync() + { + if (_api is null) + { + StatusMessage = "Client API de profil indisponible."; + return; + } + + if (!string.Equals(UsernameConfirmation, CurrentUserName, StringComparison.Ordinal)) + { + StatusMessage = "Saisissez exactement votre nom d’utilisateur pour confirmer la suppression."; + return; + } + + IsBusy = true; + try + { + await _api.CallAsync(HttpMethod.Delete, BuildEndpoint("account/me"), new DeleteAccountRequest + { + UsernameConfirmation = UsernameConfirmation + }).ConfigureAwait(true); + UsernameConfirmation = string.Empty; + StatusMessage = "Compte supprimé."; + + if (_sessionStatus is not null) + { + await _sessionStatus.LogoutAsync().ConfigureAwait(true); + } + else + { + await _api.LogoutAsync().ConfigureAwait(true); + } + } + catch (Exception ex) + { + StatusMessage = $"Impossible de supprimer le compte : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private async Task UpdateBooleanPreferenceAsync( + string path, + TRequest request, + Action applyValue, + string preferenceName) + where TRequest : class + { + IsBusy = true; + try + { + var endpoint = BuildEndpoint(path); + var response = await _api!.CallAsync(HttpMethod.Put, endpoint, request).ConfigureAwait(true); + var enabled = response.GetProperty("enabled").GetBoolean(); + applyValue(enabled); + StatusMessage = $"Préférence mise à jour : {preferenceName}."; + } + catch (Exception ex) + { + await RefreshAsync().ConfigureAwait(true); + StatusMessage = $"Impossible de mettre à jour {preferenceName} : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private string BuildEndpoint(string path) => + new Uri(new Uri(_api!.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), path).ToString(); + + private bool CanModifyAccount() => _api is not null && IsProfileLoaded && !IsBusy; +} diff --git a/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs new file mode 100644 index 000000000..91b6d0572 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PerformerConfigurationPageViewModel.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public partial class ActivitySelectionOption : ObservableObject +{ + public string Code { get; } + public string Name { get; } + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public ActivitySelectionOption(string code, string name) + { + Code = code; + Name = name; + } +} + +public partial class PerformerConfigurationPageViewModel : ViewModelBase +{ + private readonly YavscApiClient? _api; + private readonly ActivityApiClient? _activityClient; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string PerformerId { get; set; } = string.Empty; + + [ObservableProperty] + public partial string UserName { get; set; } = "—"; + + [ObservableProperty] + public partial string ExerciseCountryCode { get; set; } = "fr"; + + [ObservableProperty] + public partial string SIREN { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Website { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool Active { get; set; } + + [ObservableProperty] + public partial bool AcceptNotifications { get; set; } + + [ObservableProperty] + public partial bool AcceptPublicContact { get; set; } + + [ObservableProperty] + public partial bool UseGeoLocalizationToReduceDistanceWithClients { get; set; } + + [ObservableProperty] + public partial ObservableCollection AvailableActivities { get; set; } = new(); + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public PerformerConfigurationPageViewModel(YavscApiClient? api, ActivityApiClient? activityClient) + { + _api = api; + _activityClient = activityClient; + } + + public PerformerConfigurationPageViewModel() : this(null, null) { } + + public Task InitializeAsync() => RefreshAsync(); + + [RelayCommand] + public async Task RefreshAsync() + { + if (_api is null || _activityClient is null) + { + StatusMessage = "Client API de configuration indisponible."; + return; + } + + IsBusy = true; + try + { + var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString(); + var dto = await _api.CallAsync(HttpMethod.Get, endpoint).ConfigureAwait(true); + + PerformerId = dto?.PerformerId ?? string.Empty; + UserName = dto?.UserName ?? "—"; + ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode; + SIREN = dto?.SIREN ?? string.Empty; + Website = dto?.WebSite ?? string.Empty; + Active = dto?.Active ?? false; + AcceptNotifications = dto?.AcceptNotifications ?? false; + AcceptPublicContact = dto?.AcceptPublicContact ?? false; + UseGeoLocalizationToReduceDistanceWithClients = dto?.UseGeoLocalizationToReduceDistanceWithClients ?? false; + + var catalog = await _activityClient.GetCatalogAsync().ConfigureAwait(true); + var options = Flatten(catalog) + .OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .Select(a => new ActivitySelectionOption(a.Code, a.Name) + { + IsSelected = dto?.SelectedActivityCodes.Contains(a.Code, StringComparer.OrdinalIgnoreCase) == true + }) + .ToList(); + + AvailableActivities.Clear(); + foreach (var option in options) + { + AvailableActivities.Add(option); + } + + StatusMessage = "Profil performer chargé."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de charger la configuration performer : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + public PerformerProfileSettings BuildPerformerProfileSettings() + { + return new PerformerProfileSettings + { + PerformerId = string.IsNullOrWhiteSpace(PerformerId) ? string.Empty : PerformerId, + UserName = UserName, + ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode, + SIREN = SIREN, + WebSite = Website, + Active = Active, + AcceptNotifications = AcceptNotifications, + AcceptPublicContact = AcceptPublicContact, + UseGeoLocalizationToReduceDistanceWithClients = UseGeoLocalizationToReduceDistanceWithClients, + SelectedActivityCodes = AvailableActivities + .Where(a => a.IsSelected) + .Select(a => a.Code) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + }; + } + + [RelayCommand] + public async Task SaveAsync() + { + if (_api is null) + { + StatusMessage = "Client API de configuration indisponible."; + return; + } + + IsBusy = true; + try + { + var dto = BuildPerformerProfileSettings(); + + var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString(); + var response = await _api.CallAsync(HttpMethod.Put, endpoint, dto).ConfigureAwait(true); + StatusMessage = response is not null ? "Configuration performer enregistrée." : "Configuration performer enregistrée."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible d’enregistrer la configuration performer : {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private static IEnumerable Flatten(IEnumerable activities) + { + foreach (var activity in activities) + { + yield return activity; + foreach (var child in Flatten(activity.Children ?? Enumerable.Empty())) + { + yield return child; + } + } + } +} diff --git a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml new file mode 100644 index 000000000..692b4972c --- /dev/null +++ b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml @@ -0,0 +1,44 @@ + + + + + + + +