From f050ee44562b67a0a4883a3ef73cb8b08ece7836 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 9 Oct 2026 13:30:01 +0100 Subject: [PATCH] feat(account): enable native account management and register API Identity services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostIt.Tests/HomePageProviderFlowTests.cs | 6 +- .../Layout/UserProfilePageViewModel.cs | 487 +++++++++++++++++- .../Layout/PerformerConfigurationPage.axaml | 6 +- .../PostIt/Views/Layout/UserProfilePage.axaml | 314 ++++++++++- .../Views/Layout/UserProfilePage.axaml.cs | 128 +++++ .../Identity/AccountSecurityRequests.cs | 59 +++ src/Yavsc.Abstract/Identity/Me.cs | 12 + .../accounting/AccountController.cs | 475 +++++++++++++++-- src/Yavsc.Api/Program.cs | 11 + 9 files changed, 1399 insertions(+), 99 deletions(-) create mode 100644 src/Yavsc.Abstract/Identity/AccountSecurityRequests.cs diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs index 8fce7921e..21d532cd1 100644 --- a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs +++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs @@ -104,7 +104,7 @@ public class HomePageProviderFlowTests }; Assert.NotNull(vm.SaveProfileCommand); - Assert.True(vm.SaveProfileCommand.CanExecute(null)); + Assert.False(vm.SaveProfileCommand.CanExecute(null)); } [Fact] @@ -123,7 +123,7 @@ public class HomePageProviderFlowTests Assert.Equal("alice.new", payload.UserName); Assert.Equal("calendar-123", payload.GoogleCalendarId); - Assert.Equal("FR14 2004 1010 0505 0001 3M02 606", payload.BankInfoSummary); + Assert.Null(payload.BankInfoSummary); } [Fact] @@ -141,7 +141,7 @@ public class HomePageProviderFlowTests var payload = vm.BuildProfileUpdateRequest(); Assert.Equal(string.Empty, payload.GoogleCalendarId); - Assert.Equal(string.Empty, payload.BankInfoSummary); + Assert.Null(payload.BankInfoSummary); } [Fact] diff --git a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs index f5e7df49d..ed70665f0 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs @@ -1,5 +1,7 @@ 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; @@ -11,13 +13,45 @@ 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; } = "—"; @@ -42,15 +76,77 @@ public partial class UserProfilePageViewModel : ViewModelBase [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) + public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null) { _api = api; + _sessionStatus = sessionStatus; } - public UserProfilePageViewModel() : this(null) { } + public UserProfilePageViewModel() : this(null, null) { } public Task InitializeAsync() => RefreshAsync(); @@ -59,27 +155,59 @@ public partial class UserProfilePageViewModel : ViewModelBase { 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); - UserName = me?.UserName ?? "—"; - 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; + 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 = "—"; @@ -87,6 +215,18 @@ public partial class UserProfilePageViewModel : ViewModelBase 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 @@ -115,12 +255,13 @@ public partial class UserProfilePageViewModel : ViewModelBase UserName = NormalizeField(UserName, "—"), FullName = NormalizeField(FullName, "—"), Address = NormalizeField(Address, "—"), - GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré"), - BankInfoSummary = NormalizeField(BankInfoSummary, "Aucune information bancaire") + GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré") }; } - [RelayCommand] + private bool CanSaveProfile() => _api is not null && IsProfileLoaded && !IsBusy; + + [RelayCommand(CanExecute = nameof(CanSaveProfile))] public async Task SaveProfileAsync() { if (_api is null) @@ -148,4 +289,324 @@ public partial class UserProfilePageViewModel : ViewModelBase 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/Views/Layout/PerformerConfigurationPage.axaml b/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml index 56ec89f2c..ff9e0fe31 100644 --- a/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml +++ b/src/PostIt/PostIt/Views/Layout/PerformerConfigurationPage.axaml @@ -21,11 +21,11 @@ + PlaceholderText="Code pays (fr, en...)"/> + PlaceholderText="SIREN"/> + PlaceholderText="Site web"/> diff --git a/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml b/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml index ad190e6da..5e469a0f5 100644 --- a/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml +++ b/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml @@ -1,38 +1,298 @@ + Header="Mon compte"> - - + + + + + + + +