feat(account): enable native account management and register API Identity services
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-09 13:30:01 +01:00
commit f050ee4456
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
9 changed files with 1402 additions and 102 deletions

View file

@ -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]

View file

@ -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<BankAccountInfo> BankAccounts { get; } = [];
public ObservableCollection<LinkedExternalLogin> ExternalLogins { get; } = [];
public ObservableCollection<CalendarOption> 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<Me>(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<BankAccountInfo>(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<JsonElement>(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<CalendarOption[]>(
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<TRequest>(
string path,
TRequest request,
Action<bool> applyValue,
string preferenceName)
where TRequest : class
{
IsBusy = true;
try
{
var endpoint = BuildEndpoint(path);
var response = await _api!.CallAsync<System.Text.Json.JsonElement>(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;
}

View file

@ -21,11 +21,11 @@
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
<TextBox Text="{Binding ExerciseCountryCode, Mode=TwoWay}"
Watermark="Code pays (fr, en...)"/>
PlaceholderText="Code pays (fr, en...)"/>
<TextBox Text="{Binding SIREN, Mode=TwoWay}"
Watermark="SIREN"/>
PlaceholderText="SIREN"/>
<TextBox Text="{Binding Website, Mode=TwoWay}"
Watermark="Site web"/>
PlaceholderText="Site web"/>
<CheckBox Content="Disponible / actif"
IsChecked="{Binding Active, Mode=TwoWay}"/>

View file

@ -1,38 +1,298 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:identity="using:Yavsc.Abstract.Identity"
x:Class="PostIt.Views.UserProfilePage"
x:DataType="vm:UserProfilePageViewModel"
Header="Mon profil">
Header="Mon compte">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Mon profil"
FontSize="22"
FontWeight="SemiBold"/>
<ScrollViewer>
<StackPanel Margin="16" Spacing="16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="4">
<TextBlock Text="Gérer votre compte"
FontSize="22"
FontWeight="SemiBold"/>
<TextBlock Text="Paramètres de votre compte"
Foreground="#4B5563"/>
</StackPanel>
<Button Grid.Column="1"
Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"
VerticalAlignment="Center"/>
</Grid>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="12">
<TextBlock Text="Paramètres du compte"
FontSize="18"
FontWeight="SemiBold"/>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*" RowSpacing="8">
<TextBox Watermark="Nom d’utilisateur" Text="{Binding UserName}"/>
<TextBox Grid.Row="1" Watermark="Nom complet" Text="{Binding FullName}"/>
<TextBox Grid.Row="2" Watermark="Adresse" Text="{Binding Address}" AcceptsReturn="True" TextWrapping="Wrap"/>
<TextBox Grid.Row="3" Watermark="Calendrier Google" Text="{Binding DedicatedGoogleCalendar}"/>
<TextBox Grid.Row="4" Watermark="Détails bancaires" Text="{Binding BankInfoSummary}" AcceptsReturn="True" TextWrapping="Wrap"/>
<Button Grid.Row="5" Content="Enregistrer" Command="{Binding SaveProfileCommand}" IsEnabled="{Binding !IsBusy}"/>
</Grid>
<Grid ColumnDefinitions="150,*"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"
RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Nom d’utilisateur"
VerticalAlignment="Center"/>
<TextBox Grid.Row="0"
Grid.Column="1"
Text="{Binding UserName, Mode=TwoWay}"/>
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}"/>
<TextBlock Text="{Binding Roles, StringFormat='Rôles : {0}'}"/>
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier dédié : {0}'}"/>
<TextBlock Text="{Binding BankInfoSummary, StringFormat='Informations bancaires : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Avatar, StringFormat='Avatar : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding StatusMessage}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Grid.Row="1"
Text="Adresse e-mail"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="1"
Grid.Column="1"
Text="{Binding Email}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="2"
Text="Nom complet"
VerticalAlignment="Center"/>
<TextBox Grid.Row="2"
Grid.Column="1"
Text="{Binding FullName, Mode=TwoWay}"/>
<TextBlock Grid.Row="3"
Text="Rôles"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="3"
Grid.Column="1"
Text="{Binding Roles}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="4"
Text="Adresse"
VerticalAlignment="Top"/>
<TextBox Grid.Row="4"
Grid.Column="1"
Text="{Binding Address, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="64"/>
<TextBlock Grid.Row="5"
Text="Avatar"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="5"
Grid.Column="1"
Text="{Binding Avatar}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="6"
Text="Calendrier Google"
VerticalAlignment="Center"/>
<TextBox Grid.Row="6"
Grid.Column="1"
Text="{Binding DedicatedGoogleCalendar, Mode=TwoWay}"/>
<TextBlock Grid.Row="7"
Text="Courriel mensuel"
VerticalAlignment="Center"/>
<StackPanel Grid.Row="7"
Grid.Column="1"
Orientation="Horizontal"
Spacing="8">
<CheckBox Content="Recevoir les nouvelles mensuelles"
IsChecked="{Binding AllowMonthlyEmail, Mode=TwoWay}"/>
<Button Content="Enregistrer"
Command="{Binding SetMonthlyEmailPreferenceCommand}"
IsEnabled="{Binding !IsBusy}"/>
</StackPanel>
<TextBlock Grid.Row="8"
Text="E-mail confirmé"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="8"
Grid.Column="1"
Text="{Binding EmailConfirmed}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir un avatar"
Click="ChooseAvatar_Click"
IsEnabled="{Binding !IsBusy}"/>
<Button Content="Enregistrer le profil"
Command="{Binding SaveProfileCommand}"/>
</StackPanel>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="10">
<TextBlock Text="Sécurité et connexions"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="{Binding Email, StringFormat='E-mail : {0}'}"/>
<TextBlock Text="{Binding EmailConfirmed, StringFormat='Adresse confirmée : {0}'}"/>
<Button Content="Renvoyer le courriel de confirmation"
Command="{Binding SendEmailConfirmationCommand}"
IsVisible="{Binding !EmailConfirmed}"/>
<TextBlock Text="{Binding ExternalLoginCount, StringFormat='Connexions externes : {0}'}"/>
<ItemsControl ItemsSource="{Binding ExternalLogins}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="identity:LinkedExternalLogin">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Text="{Binding DisplayName}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Content="Dissocier"
Command="{Binding $parent[ContentPage].((vm:UserProfilePageViewModel)DataContext).RemoveExternalLoginCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<CheckBox Content="Activer l’authentification à deux facteurs"
IsChecked="{Binding TwoFactorEnabled, Mode=TwoWay}"/>
<Button Content="Enregistrer le réglage de sécurité"
Command="{Binding SetTwoFactorCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Right"/>
<TextBlock Text="{Binding HasPassword, StringFormat='Mot de passe local configuré : {0}'}"/>
<TextBox PlaceholderText="Mot de passe actuel"
PasswordChar="●"
Text="{Binding CurrentPassword, Mode=TwoWay}"
IsVisible="{Binding HasPassword}"/>
<TextBox PlaceholderText="Nouveau mot de passe"
PasswordChar="●"
Text="{Binding NewPassword, Mode=TwoWay}"/>
<TextBox PlaceholderText="Confirmer le nouveau mot de passe"
PasswordChar="●"
Text="{Binding ConfirmPassword, Mode=TwoWay}"/>
<Button Content="Modifier le mot de passe"
Command="{Binding ChangePasswordCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Right"/>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="10">
<TextBlock Text="Informations bancaires"
FontSize="18"
FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding BankAccounts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="identity:BankAccountInfo">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Spacing="2">
<TextBlock Text="{Binding IBAN, StringFormat='IBAN : {0}'}"/>
<TextBlock Text="{Binding BIC, StringFormat='BIC : {0}'}"/>
</StackPanel>
<Button Grid.Column="1"
Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:UserProfilePageViewModel)DataContext).DeleteBankAccountCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Ajouter un compte bancaire"
FontWeight="SemiBold"/>
<TextBox PlaceholderText="IBAN" Text="{Binding BankIban, Mode=TwoWay}"/>
<TextBox PlaceholderText="BIC" Text="{Binding BankBic, Mode=TwoWay}"/>
<Grid ColumnDefinitions="*,*" ColumnSpacing="8">
<TextBox PlaceholderText="Code banque" Text="{Binding BankCode, Mode=TwoWay}"/>
<TextBox Grid.Column="1" PlaceholderText="Code guichet" Text="{Binding BankWicketCode, Mode=TwoWay}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<TextBox PlaceholderText="Numéro de compte" Text="{Binding BankAccountNumber, Mode=TwoWay}"/>
<NumericUpDown Grid.Column="1"
Width="120"
Minimum="0"
Maximum="99"
Value="{Binding BankedKey, Mode=TwoWay}"/>
</Grid>
<Button Content="Ajouter les informations bancaires"
Command="{Binding AddBankAccountCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Right"/>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="10">
<TextBlock Text="Autres paramètres du compte"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="{Binding PostsCounter, StringFormat='Articles publiés : {0}'}"/>
<TextBlock Text="{Binding DiskUsage, StringFormat='Espace utilisé (octets) : {0}'}"/>
<TextBlock Text="{Binding DiskQuota, StringFormat='Quota disque (octets) : {0}'}"/>
<TextBlock Text="{Binding Credits, StringFormat='Crédits : {0}'}"/>
<TextBlock Text="{Binding UserId, StringFormat='Identifiant utilisateur : {0}'}"/>
<TextBlock Text="Calendrier Google"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Charger les calendriers"
Command="{Binding LoadCalendarsCommand}"/>
<Button Content="Désactiver le calendrier"
Command="{Binding ChooseCalendarCommand}"
CommandParameter="{x:Null}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Calendars}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="identity:CalendarOption">
<Button Content="{Binding Name}"
Command="{Binding $parent[ContentPage].((vm:UserProfilePageViewModel)DataContext).ChooseCalendarCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Gérer mes cercles" Click="OpenCircles_Click"/>
<Button Content="Gérer mon activité professionnelle" Click="OpenPerformerConfiguration_Click"/>
<Button Content="Mes publications" Click="OpenBlogs_Click"/>
</StackPanel>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"
CornerRadius="6">
<StackPanel Spacing="8">
<TextBlock Text="Supprimer le compte"
FontSize="18"
FontWeight="SemiBold"/>
<TextBlock Text="Cette action est définitive. Saisissez votre nom d’utilisateur actuel pour confirmer."
TextWrapping="Wrap"/>
<TextBox PlaceholderText="{Binding CurrentUserName, StringFormat='Saisir {0} pour confirmer'}"
Text="{Binding UsernameConfirmation, Mode=TwoWay}"/>
<Button Content="Supprimer définitivement mon compte"
Command="{Binding DeleteAccountCommand}"
HorizontalAlignment="Right"/>
</StackPanel>
</Border>
<TextBlock Text="{Binding StatusMessage}"
Foreground="#4B5563"
TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -1,4 +1,12 @@
using System;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -8,4 +16,124 @@ public partial class UserProfilePage : ContentPage
{
InitializeComponent();
}
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not UserProfilePageViewModel viewModel)
{
return;
}
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
viewModel.StatusMessage = "Impossible d’accéder au sélecteur de fichiers.";
return;
}
viewModel.IsBusy = true;
try
{
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir un avatar",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Images")
{
Patterns = ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif", "*.bmp", "*.tif", "*.tiff"]
}
]
});
var file = files.FirstOrDefault();
if (file is null)
{
return;
}
var properties = await file.GetBasicPropertiesAsync();
if (properties.Size is > 2 * 1024 * 1024)
{
viewModel.StatusMessage = "L’avatar ne doit pas dépasser 2 Mo.";
return;
}
var app = (App?)Application.Current;
var api = app?.ServiceProvider?.GetRequiredService<YavscApiClient>();
if (api is null)
{
viewModel.StatusMessage = "Client API indisponible.";
return;
}
await using var stream = await file.OpenReadAsync();
var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
await viewModel.RefreshAsync();
if (viewModel.IsProfileLoaded)
{
viewModel.StatusMessage = message;
}
}
catch (Exception ex)
{
viewModel.StatusMessage = $"Impossible d’envoyer l’avatar : {ex.Message}";
}
finally
{
viewModel.IsBusy = false;
}
}
private async void OpenCircles_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var app = (App?)Application.Current;
var viewModel = app?.ServiceProvider?.GetRequiredService<CirclesPageViewModel>();
if (app is null || viewModel is null)
{
if (DataContext is UserProfilePageViewModel profile)
{
profile.StatusMessage = "La page des cercles est indisponible.";
}
return;
}
await viewModel.RefreshAsync();
await app.PushPageAsync(viewModel);
}
private async void OpenPerformerConfiguration_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var app = (App?)Application.Current;
var viewModel = app?.ServiceProvider?.GetRequiredService<PerformerConfigurationPageViewModel>();
if (app is null || viewModel is null)
{
if (DataContext is UserProfilePageViewModel profile)
{
profile.StatusMessage = "La configuration professionnelle est indisponible.";
}
return;
}
await viewModel.InitializeAsync();
await app.PushPageAsync(viewModel);
}
private async void OpenBlogs_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await App.PushBlogsPageAsync();
}
private static string GetMimeType(string fileName) =>
Path.GetExtension(fileName).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
".tif" or ".tiff" => "image/tiff",
_ => "application/octet-stream"
};
}