Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f050ee4456 |
|||
|
5e22b9a232 |
|||
|
008295c8f6 |
|||
|
6b75ae7682 |
|||
|
05e35d0677 |
|||
|
137665bfe0 |
|||
|
af1e572f32 |
40 changed files with 8148 additions and 42 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Yavsc.Abstract.Identity;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
@ -12,4 +13,157 @@ public class HomePageProviderFlowTests
|
||||||
Assert.NotNull(vm.OpenProviderRequests);
|
Assert.NotNull(vm.OpenProviderRequests);
|
||||||
Assert.True(vm.OpenProviderRequests.CanExecute(null));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs
Normal file
117
src/PostIt/PostIt.Tests/ResourceUsageApiClientTests.cs
Normal file
|
|
@ -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<T> CallAsync<T>(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<ResourceUsageSummary>))
|
||||||
|
{
|
||||||
|
var list = new List<ResourceUsageSummary>
|
||||||
|
{
|
||||||
|
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<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> 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<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(Array.Empty<byte>());
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,9 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<EstimateListPage>();
|
services.AddTransient<EstimateListPage>();
|
||||||
services.AddTransient<EstimateValidationPage>();
|
services.AddTransient<EstimateValidationPage>();
|
||||||
services.AddTransient<MyFilesPage>();
|
services.AddTransient<MyFilesPage>();
|
||||||
|
services.AddTransient<UserProfilePage>();
|
||||||
|
services.AddTransient<PerformerConfigurationPage>();
|
||||||
|
services.AddTransient<AdministrationPage>();
|
||||||
services.AddTransient<ResourceUsagePage>();
|
services.AddTransient<ResourceUsagePage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
|
|
@ -95,6 +98,9 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<AboutPageViewModel>();
|
services.AddSingleton<AboutPageViewModel>();
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
|
services.AddTransient<UserProfilePageViewModel>();
|
||||||
|
services.AddTransient<PerformerConfigurationPageViewModel>();
|
||||||
|
services.AddTransient<AdministrationPageViewModel>();
|
||||||
services.AddSingleton<SignaturePageViewModel>();
|
services.AddSingleton<SignaturePageViewModel>();
|
||||||
services.AddSingleton<CirclesPageViewModel>();
|
services.AddSingleton<CirclesPageViewModel>();
|
||||||
services.AddSingleton<ActivitiesPageViewModel>();
|
services.AddSingleton<ActivitiesPageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ public class ViewLocator : IDataTemplate
|
||||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||||
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
||||||
|
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
|
||||||
|
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
|
||||||
|
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
|
||||||
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
|
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
|
||||||
ChatViewModel => services.GetRequiredService<ChatPage>(),
|
ChatViewModel => services.GetRequiredService<ChatPage>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
|
|
|
||||||
96
src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs
Normal file
96
src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs
Normal file
|
|
@ -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<UserInfo> Users { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SearchText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = "Chargement…";
|
||||||
|
|
||||||
|
public IReadOnlyList<UserInfo> 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<List<UserInfo>>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,9 @@ public class HomePageViewModel : ViewModelBase
|
||||||
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||||
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||||
|
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
|
||||||
|
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
|
||||||
|
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
|
||||||
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
|
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
|
||||||
OpenChat = new AsyncRelayCommand(OpenChatAsync);
|
OpenChat = new AsyncRelayCommand(OpenChatAsync);
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +46,9 @@ public class HomePageViewModel : ViewModelBase
|
||||||
|
|
||||||
public IAsyncRelayCommand OpenChat { get; }
|
public IAsyncRelayCommand OpenChat { get; }
|
||||||
public IAsyncRelayCommand OpenBlogs { get; }
|
public IAsyncRelayCommand OpenBlogs { get; }
|
||||||
|
public IAsyncRelayCommand OpenProfile { get; }
|
||||||
|
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
|
||||||
|
public IAsyncRelayCommand OpenAdministration { get; }
|
||||||
public IAsyncRelayCommand OpenResourceUsage { get; }
|
public IAsyncRelayCommand OpenResourceUsage { get; }
|
||||||
public IAsyncRelayCommand OpenActivities { get; }
|
public IAsyncRelayCommand OpenActivities { get; }
|
||||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||||
|
|
@ -140,6 +146,60 @@ public class HomePageViewModel : ViewModelBase
|
||||||
await app.PushPageAsync(vm);
|
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<UserProfilePageViewModel>();
|
||||||
|
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<PerformerConfigurationPageViewModel>();
|
||||||
|
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<AdministrationPageViewModel>();
|
||||||
|
if (vm is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Page administration indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
await app.PushPageAsync(vm);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OpenResourceUsageAsync()
|
private async Task OpenResourceUsageAsync()
|
||||||
{
|
{
|
||||||
var app = (App?)Application.Current;
|
var app = (App?)Application.Current;
|
||||||
|
|
|
||||||
612
src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs
Normal file
612
src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs
Normal file
|
|
@ -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<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, 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<Me>(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<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;
|
||||||
|
}
|
||||||
|
|
@ -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<ActivitySelectionOption> 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<PerformerProfileSettings>(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<PerformerProfileSettings>(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<ActivityInfo> Flatten(IEnumerable<ActivityInfo> activities)
|
||||||
|
{
|
||||||
|
foreach (var activity in activities)
|
||||||
|
{
|
||||||
|
yield return activity;
|
||||||
|
foreach (var child in Flatten(activity.Children ?? Enumerable.Empty<ActivityInfo>()))
|
||||||
|
{
|
||||||
|
yield return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml
Normal file
44
src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
x:Class="PostIt.Views.AdministrationPage"
|
||||||
|
x:DataType="vm:AdministrationPageViewModel"
|
||||||
|
Header="Administration">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
Text="Administration"
|
||||||
|
FontSize="22"
|
||||||
|
FontWeight="SemiBold"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1" Spacing="8">
|
||||||
|
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
|
||||||
|
PlaceholderText="Rechercher un utilisateur..."/>
|
||||||
|
<Button Content="Actualiser"
|
||||||
|
Command="{Binding RefreshCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<ProgressBar IsVisible="{Binding IsBusy}"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
Height="8"/>
|
||||||
|
<TextBlock Text="{Binding StatusMessage}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ListBox Grid.Row="3"
|
||||||
|
ItemsSource="{Binding FilteredUsers}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
11
src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml.cs
Normal file
11
src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class AdministrationPage : ContentPage
|
||||||
|
{
|
||||||
|
public AdministrationPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,18 @@
|
||||||
Command="{Binding OpenMyFiles}"
|
Command="{Binding OpenMyFiles}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
<Button Content="Mon profil"
|
||||||
|
Command="{Binding OpenProfile}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
<Button Content="Mon activité / performer"
|
||||||
|
Command="{Binding OpenPerformerConfiguration}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
<Button Content="Administration"
|
||||||
|
Command="{Binding OpenAdministration}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Mon usage / administration"
|
<Button Content="Mon usage / administration"
|
||||||
Command="{Binding OpenResourceUsage}"
|
Command="{Binding OpenResourceUsage}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
x:Class="PostIt.Views.PerformerConfigurationPage"
|
||||||
|
x:DataType="vm:PerformerConfigurationPageViewModel"
|
||||||
|
Header="Mon activité / performer">
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="16" Spacing="12">
|
||||||
|
<TextBlock Text="Mon activité / performer"
|
||||||
|
FontSize="22"
|
||||||
|
FontWeight="SemiBold"/>
|
||||||
|
|
||||||
|
<Button Content="Actualiser"
|
||||||
|
Command="{Binding RefreshCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
|
||||||
|
<ProgressBar IsVisible="{Binding IsBusy}"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
Height="8"/>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
|
||||||
|
<TextBox Text="{Binding ExerciseCountryCode, Mode=TwoWay}"
|
||||||
|
PlaceholderText="Code pays (fr, en...)"/>
|
||||||
|
<TextBox Text="{Binding SIREN, Mode=TwoWay}"
|
||||||
|
PlaceholderText="SIREN"/>
|
||||||
|
<TextBox Text="{Binding Website, Mode=TwoWay}"
|
||||||
|
PlaceholderText="Site web"/>
|
||||||
|
|
||||||
|
<CheckBox Content="Disponible / actif"
|
||||||
|
IsChecked="{Binding Active, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Content="Accepter les notifications"
|
||||||
|
IsChecked="{Binding AcceptNotifications, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Content="Accepter les demandes publiques"
|
||||||
|
IsChecked="{Binding AcceptPublicContact, Mode=TwoWay}"/>
|
||||||
|
<CheckBox Content="Utiliser la géolocalisation"
|
||||||
|
IsChecked="{Binding UseGeoLocalizationToReduceDistanceWithClients, Mode=TwoWay}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Activités" FontWeight="SemiBold"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding AvailableActivities}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<CheckBox Margin="0,0,0,6"
|
||||||
|
Content="{Binding Name}"
|
||||||
|
IsChecked="{Binding IsSelected, Mode=TwoWay}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button Content="Enregistrer"
|
||||||
|
Command="{Binding SaveCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding StatusMessage}"
|
||||||
|
Foreground="#4B5563"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentPage>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class PerformerConfigurationPage : ContentPage
|
||||||
|
{
|
||||||
|
public PerformerConfigurationPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml
Normal file
298
src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml
Normal file
|
|
@ -0,0 +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 compte">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 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 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>
|
||||||
139
src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml.cs
Normal file
139
src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
public partial class UserProfilePage : ContentPage
|
||||||
|
{
|
||||||
|
public UserProfilePage()
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,13 @@ namespace Yavsc
|
||||||
public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$";
|
public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$";
|
||||||
public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$";
|
public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$";
|
||||||
|
|
||||||
|
public const string ResourceUsagePath = "resource-usage";
|
||||||
|
public const string ResourceUsageCurrentUserRoute = "me";
|
||||||
|
public const string ResourceUsageAdminRoute = "admin";
|
||||||
|
public const string ResourceUsageDefaultCurrency = "EUR";
|
||||||
|
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
||||||
|
public const string ResourceUsageAdminDisplayName = "administrator";
|
||||||
|
|
||||||
public const string SigninPath = "~/signin";
|
public const string SigninPath = "~/signin";
|
||||||
public const string LogoutPath = "~/signout";
|
public const string LogoutPath = "~/signout";
|
||||||
|
|
||||||
|
|
|
||||||
59
src/Yavsc.Abstract/Identity/AccountSecurityRequests.cs
Normal file
59
src/Yavsc.Abstract/Identity/AccountSecurityRequests.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Yavsc.Abstract.Identity;
|
||||||
|
|
||||||
|
public sealed class ChangePasswordRequest
|
||||||
|
{
|
||||||
|
public string CurrentPassword { get; set; } = string.Empty;
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TwoFactorRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MonthlyEmailPreferenceRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DeleteAccountRequest
|
||||||
|
{
|
||||||
|
public string UsernameConfirmation { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LinkedExternalLogin
|
||||||
|
{
|
||||||
|
public string Provider { get; set; } = string.Empty;
|
||||||
|
public string ProviderKey { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BankAccountInfo
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string IBAN { get; set; } = string.Empty;
|
||||||
|
public string BIC { get; set; } = string.Empty;
|
||||||
|
public string BankCode { get; set; } = string.Empty;
|
||||||
|
public string WicketCode { get; set; } = string.Empty;
|
||||||
|
public string AccountNumber { get; set; } = string.Empty;
|
||||||
|
public int BankedKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BankAccountInfoRequest
|
||||||
|
{
|
||||||
|
public string IBAN { get; set; } = string.Empty;
|
||||||
|
public string BIC { get; set; } = string.Empty;
|
||||||
|
public string BankCode { get; set; } = string.Empty;
|
||||||
|
public string WicketCode { get; set; } = string.Empty;
|
||||||
|
public string AccountNumber { get; set; } = string.Empty;
|
||||||
|
public int BankedKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CalendarOption
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
namespace Yavsc.Abstract.Identity {
|
namespace Yavsc.Abstract.Identity {
|
||||||
public class Me : UserInfo
|
public class Me : UserInfo
|
||||||
{
|
{
|
||||||
public AuthToken Token {
|
public bool EmailConfirmed { get; set; }
|
||||||
|
public bool AllowMonthlyEmail { get; set; }
|
||||||
|
public bool HasPassword { get; set; }
|
||||||
|
public bool TwoFactorEnabled { get; set; }
|
||||||
|
public int ExternalLoginCount { get; set; }
|
||||||
|
public long PostsCounter { get; set; }
|
||||||
|
public long DiskUsage { get; set; }
|
||||||
|
public long DiskQuota { get; set; }
|
||||||
|
public decimal Credits { get; set; }
|
||||||
|
public BankAccountInfo[] BankAccounts { get; set; } = [];
|
||||||
|
public LinkedExternalLogin[] ExternalLogins { get; set; } = [];
|
||||||
|
|
||||||
|
public AuthToken? Token {
|
||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/Yavsc.Abstract/Identity/ProfileUpdateRequest.cs
Normal file
12
src/Yavsc.Abstract/Identity/ProfileUpdateRequest.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace Yavsc.Abstract.Identity;
|
||||||
|
|
||||||
|
public class ProfileUpdateRequest
|
||||||
|
{
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? GoogleCalendarId { get; set; }
|
||||||
|
public string? BankInfoSummary { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
namespace Yavsc.Abstract.Identity
|
namespace Yavsc.Abstract.Identity
|
||||||
{
|
{
|
||||||
public class UserInfo {
|
public class UserInfo {
|
||||||
|
|
||||||
public UserInfo()
|
public UserInfo()
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserInfo(string userId, string userName, string email, string avatar)
|
public UserInfo(string userId, string userName, string email, string avatar)
|
||||||
|
|
@ -14,10 +15,15 @@ namespace Yavsc.Abstract.Identity
|
||||||
Email = email;
|
Email = email;
|
||||||
Avatar = avatar;
|
Avatar = avatar;
|
||||||
}
|
}
|
||||||
public string UserId { get; set; }
|
|
||||||
|
|
||||||
public string UserName { get; set; }
|
public string UserId { get; set; } = string.Empty;
|
||||||
public string Email { get; }
|
public string? UserName { get; set; }
|
||||||
public string Avatar { get; set; }
|
public string? FullName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? Avatar { get; set; }
|
||||||
|
public string[] Roles { get; set; } = [];
|
||||||
|
public string? DedicatedGoogleCalendar { get; set; }
|
||||||
|
public string? BankInfoSummary { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/Yavsc.Abstract/Resources/ResourceUsageSummary.cs
Normal file
22
src/Yavsc.Abstract/Resources/ResourceUsageSummary.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace Yavsc.Abstract.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight usage snapshot for PostIt / Yavsc resource accounting.
|
||||||
|
/// The initial implementation is intentionally intentionally small and
|
||||||
|
/// serialisable so it can be exposed through the API and consumed by the
|
||||||
|
/// PostIt client without requiring a full database model yet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ResourceUsageSummary
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset RecordedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public decimal ApiCalls { get; set; }
|
||||||
|
public decimal CpuSeconds { get; set; }
|
||||||
|
public decimal BandwidthMb { get; set; }
|
||||||
|
public decimal StorageMb { get; set; }
|
||||||
|
|
||||||
|
public decimal EstimatedCompensation { get; set; }
|
||||||
|
public string Currency { get; set; } = Constants.ResourceUsageDefaultCurrency;
|
||||||
|
}
|
||||||
15
src/Yavsc.Abstract/Workflow/PerformerProfileSettings.cs
Normal file
15
src/Yavsc.Abstract/Workflow/PerformerProfileSettings.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace Yavsc.Abstract.Workflow;
|
||||||
|
|
||||||
|
public sealed class PerformerProfileSettings
|
||||||
|
{
|
||||||
|
public string PerformerId { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string ExerciseCountryCode { get; set; } = "fr";
|
||||||
|
public string SIREN { get; set; } = string.Empty;
|
||||||
|
public string WebSite { get; set; } = string.Empty;
|
||||||
|
public bool Active { get; set; }
|
||||||
|
public bool AcceptNotifications { get; set; }
|
||||||
|
public bool AcceptPublicContact { get; set; }
|
||||||
|
public bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
|
||||||
|
public List<string> SelectedActivityCodes { get; set; } = new();
|
||||||
|
}
|
||||||
60
src/Yavsc.Api.Client/ResourceUsageApiClient.cs
Normal file
60
src/Yavsc.Api.Client/ResourceUsageApiClient.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Abstract.Resources;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin HTTP client for the PostIt resource-usage reporting flow.
|
||||||
|
/// It follows the same pattern as the other Yavsc API clients: absolute
|
||||||
|
/// paths, JSON request/response mapping, and no application logic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ResourceUsageApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = Constants.ResourceUsagePath;
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Func<string> _businessBaseAddress;
|
||||||
|
|
||||||
|
public ResourceUsageApiClient(IYavscApiClient api, string businessBaseAddress)
|
||||||
|
: this(api, () => businessBaseAddress)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceUsageApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
|
||||||
|
_ = ResolveBusinessBaseAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ResourceUsageSummary> GetCurrentAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync<ResourceUsageSummary>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
Absolute($"{PathPrefix}/{Constants.ResourceUsageCurrentUserRoute}"),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<ResourceUsageSummary>> GetAdminOverviewAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync<List<ResourceUsageSummary>>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
Absolute($"{PathPrefix}/{Constants.ResourceUsageAdminRoute}"),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
{
|
||||||
|
var raw = _businessBaseAddress();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
throw new InvalidOperationException("Business base address is required.");
|
||||||
|
|
||||||
|
return new Uri(raw, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Abstract.Resources;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
namespace Yavsc.ApiControllers.Administration;
|
||||||
|
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize]
|
||||||
|
[Route(Constants.APIPrefix + "/" + Constants.ResourceUsagePath)]
|
||||||
|
public sealed class ResourceUsageApiController : Controller
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _dbContext;
|
||||||
|
|
||||||
|
public ResourceUsageApiController(ApplicationDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceUsageSummary ToSummary(ResourceUsageRecord record)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
UserId = record.UserId,
|
||||||
|
UserName = record.UserName,
|
||||||
|
RecordedAtUtc = new DateTimeOffset(record.RecordedAtUtc, TimeSpan.Zero),
|
||||||
|
ApiCalls = record.ApiCalls,
|
||||||
|
CpuSeconds = record.CpuSeconds,
|
||||||
|
BandwidthMb = record.BandwidthMb,
|
||||||
|
StorageMb = record.StorageMb,
|
||||||
|
EstimatedCompensation = record.EstimatedCompensation,
|
||||||
|
Currency = record.Currency
|
||||||
|
};
|
||||||
|
|
||||||
|
[HttpGet(Constants.ResourceUsageCurrentUserRoute)]
|
||||||
|
public async Task<ActionResult<ResourceUsageSummary>> GetCurrentUserUsage()
|
||||||
|
{
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var userName = User.Identity?.Name ?? Constants.ResourceUsageCurrentUserDisplayName;
|
||||||
|
var record = await _dbContext.ResourceUsageRecords
|
||||||
|
.Where(r => r.UserId == userId)
|
||||||
|
.OrderByDescending(r => r.RecordedAtUtc)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
var zeroUsage = new ResourceUsageRecord
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
UserName = userName,
|
||||||
|
RecordedAtUtc = DateTime.UtcNow,
|
||||||
|
ApiCalls = 0m,
|
||||||
|
CpuSeconds = 0m,
|
||||||
|
BandwidthMb = 0m,
|
||||||
|
StorageMb = 0m,
|
||||||
|
EstimatedCompensation = 0m,
|
||||||
|
Currency = Constants.ResourceUsageDefaultCurrency,
|
||||||
|
UserCreated = userId,
|
||||||
|
UserModified = userId,
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
_dbContext.ResourceUsageRecords.Add(zeroUsage);
|
||||||
|
await _dbContext.SaveChangesAsync(userId);
|
||||||
|
return Ok(ToSummary(zeroUsage));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ToSummary(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(Constants.ResourceUsageAdminRoute)]
|
||||||
|
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
|
||||||
|
{
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestByUser = (await _dbContext.ResourceUsageRecords
|
||||||
|
.OrderByDescending(r => r.RecordedAtUtc)
|
||||||
|
.ToListAsync())
|
||||||
|
.GroupBy(r => r.UserId)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.OrderByDescending(r => r.RecordedAtUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(latestByUser.Select(ToSummary).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Razor;
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
|
@ -129,6 +130,9 @@ namespace Yavsc.ApiControllers
|
||||||
Temp = siteSettings.TempDir,
|
Temp = siteSettings.TempDir,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
FileInfo? renderedPdf = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
|
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
|
||||||
|
|
@ -141,10 +145,28 @@ namespace Yavsc.ApiControllers
|
||||||
// we stream the generated PDF bytes — but driving the
|
// we stream the generated PDF bytes — but driving the
|
||||||
// generation through the view keeps the template as the
|
// generation through the view keeps the template as the
|
||||||
// output producer, per the original design.
|
// output producer, per the original design.
|
||||||
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists)
|
renderedPdf = model.GenerateEstimatePdf();
|
||||||
|
if (renderedPdf is FileInfo fo && fo.Exists)
|
||||||
{
|
{
|
||||||
return File(fo.OpenRead(), "application/pdf");
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
dbContext,
|
||||||
|
uid,
|
||||||
|
User.Identity?.Name ?? uid,
|
||||||
|
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
|
||||||
|
storageBytes: fo.Length,
|
||||||
|
actorUserId: uid);
|
||||||
|
|
||||||
|
return File(fo.OpenRead(), "application/pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
dbContext,
|
||||||
|
uid,
|
||||||
|
User.Identity?.Name ?? uid,
|
||||||
|
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
|
||||||
|
storageBytes: 0,
|
||||||
|
actorUserId: uid);
|
||||||
|
|
||||||
string msg = "TeX render failed: " + model.GenerationErrorMessage;
|
string msg = "TeX render failed: " + model.GenerationErrorMessage;
|
||||||
logger.LogError(msg);
|
logger.LogError(msg);
|
||||||
return Problem(msg);
|
return Problem(msg);
|
||||||
|
|
@ -152,6 +174,14 @@ namespace Yavsc.ApiControllers
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
dbContext,
|
||||||
|
uid,
|
||||||
|
User.Identity?.Name ?? uid,
|
||||||
|
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
|
||||||
|
storageBytes: renderedPdf?.Length ?? 0,
|
||||||
|
actorUserId: uid);
|
||||||
|
|
||||||
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
|
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
|
||||||
return Problem("TeX render failed: " + ex.Message);
|
return Problem("TeX render failed: " + ex.Message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using Microsoft.AspNetCore.Identity;
|
#nullable enable
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -9,6 +11,10 @@ using Yavsc.Api.Helpers;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Yavsc.Abstract.Identity;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
|
using Yavsc.Interface;
|
||||||
|
using Yavsc.Services;
|
||||||
|
|
||||||
namespace Yavsc.WebApi.Controllers
|
namespace Yavsc.WebApi.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -30,35 +36,100 @@ namespace Yavsc.WebApi.Controllers
|
||||||
readonly ApplicationDbContext _dbContext;
|
readonly ApplicationDbContext _dbContext;
|
||||||
private readonly SiteSettings siteSettings;
|
private readonly SiteSettings siteSettings;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly ITrueEmailSender _emailSender;
|
||||||
|
private readonly ICalendarManager _calendarManager;
|
||||||
|
|
||||||
public ApiAccountController(
|
public ApiAccountController(
|
||||||
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
|
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
|
||||||
IOptions<SiteSettings> siteSettings)
|
IOptions<SiteSettings> siteSettings, UserManager<ApplicationUser> userManager,
|
||||||
|
ITrueEmailSender emailSender, ICalendarManager calendarManager)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
|
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
this.siteSettings = siteSettings.Value;
|
this.siteSettings = siteSettings.Value;
|
||||||
|
_userManager = userManager;
|
||||||
|
_emailSender = emailSender;
|
||||||
|
_calendarManager = calendarManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
public async Task<IActionResult> Me()
|
public async Task<IActionResult> Me()
|
||||||
{
|
{
|
||||||
if (User == null)
|
if (User?.Identity?.IsAuthenticated != true)
|
||||||
return new BadRequestObjectResult(
|
{
|
||||||
new { error = "user not found" });
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
var uid = User.GetUserId();
|
var uid = User.GetUserId();
|
||||||
Debug.Assert(uid != null, "uid is null");
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
var userData = await GetUserData(uid);
|
{
|
||||||
Debug.Assert(userData != null, "userData is null");
|
return Challenge();
|
||||||
var user = new Yavsc.Models.Auth.Me(userData.Id, userData.UserName, userData.Email,
|
}
|
||||||
userData.Avatar,
|
|
||||||
userData.PostalAddress, userData.DedicatedGoogleCalendar);
|
|
||||||
|
|
||||||
var userRoles = _dbContext.UserRoles.Where(u => u.UserId == uid).Select(r => r.RoleId).ToArray();
|
var userData = await _dbContext.Users
|
||||||
|
.Include(u => u.PostalAddress)
|
||||||
|
.Include(u => u.AccountBalance)
|
||||||
|
.Include(u => u.BankInfo)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == uid);
|
||||||
|
|
||||||
IdentityRole[] roles = _dbContext.Roles.Where(r => userRoles.Contains(r.Id)).ToArray();
|
if (userData is null)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "user not found" });
|
||||||
|
}
|
||||||
|
|
||||||
user.Roles = roles.Select(r => r.Name).ToArray();
|
var user = new Yavsc.Abstract.Identity.Me
|
||||||
|
{
|
||||||
|
UserId = userData.Id,
|
||||||
|
UserName = userData.UserName,
|
||||||
|
Email = userData.Email,
|
||||||
|
Avatar = userData.Avatar,
|
||||||
|
FullName = userData.FullName,
|
||||||
|
Address = userData.PostalAddress?.Address ?? string.Empty,
|
||||||
|
DedicatedGoogleCalendar = userData.DedicatedGoogleCalendar
|
||||||
|
};
|
||||||
|
user.BankInfoSummary = userData.BankInfo is { Count: > 0 }
|
||||||
|
? string.Join(" | ", userData.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var userRoles = await _dbContext.UserRoles
|
||||||
|
.Where(u => u.UserId == uid)
|
||||||
|
.Select(r => r.RoleId)
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var roles = await _dbContext.Roles
|
||||||
|
.Where(r => userRoles.Contains(r.Id))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
user.Roles = roles.Select(r => r.Name).OfType<string>().ToArray();
|
||||||
|
user.EmailConfirmed = await _userManager.IsEmailConfirmedAsync(userData);
|
||||||
|
user.AllowMonthlyEmail = userData.AllowMonthlyEmail;
|
||||||
|
user.HasPassword = await _userManager.HasPasswordAsync(userData);
|
||||||
|
user.TwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(userData);
|
||||||
|
var externalLogins = await _userManager.GetLoginsAsync(userData);
|
||||||
|
user.ExternalLoginCount = externalLogins.Count;
|
||||||
|
user.ExternalLogins = externalLogins.Select(login => new LinkedExternalLogin
|
||||||
|
{
|
||||||
|
Provider = login.LoginProvider,
|
||||||
|
ProviderKey = login.ProviderKey,
|
||||||
|
DisplayName = login.ProviderDisplayName ?? login.LoginProvider
|
||||||
|
}).ToArray();
|
||||||
|
user.PostsCounter = await _dbContext.BlogSpot.LongCountAsync(x => x.AuthorId == uid);
|
||||||
|
user.DiskUsage = userData.DiskUsage;
|
||||||
|
user.DiskQuota = userData.DiskQuota;
|
||||||
|
user.Credits = userData.AccountBalance?.Credits ?? 0;
|
||||||
|
user.BankAccounts = (userData.BankInfo ?? [])
|
||||||
|
.Select(bank => new BankAccountInfo
|
||||||
|
{
|
||||||
|
Id = bank.Id,
|
||||||
|
IBAN = bank.IBAN,
|
||||||
|
BIC = bank.BIC,
|
||||||
|
BankCode = bank.BankCode,
|
||||||
|
WicketCode = bank.WicketCode,
|
||||||
|
AccountNumber = bank.AccountNumber,
|
||||||
|
BankedKey = bank.BankedKey
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return Ok(user);
|
return Ok(user);
|
||||||
}
|
}
|
||||||
|
|
@ -68,9 +139,571 @@ namespace Yavsc.WebApi.Controllers
|
||||||
return await _dbContext.Users
|
return await _dbContext.Users
|
||||||
.Include(u => u.PostalAddress)
|
.Include(u => u.PostalAddress)
|
||||||
.Include(u => u.AccountBalance)
|
.Include(u => u.AccountBalance)
|
||||||
|
.Include(u => u.BankInfo)
|
||||||
.FirstAsync(u => u.Id == uid);
|
.FirstAsync(u => u.Id == uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("me")]
|
||||||
|
public async Task<IActionResult> UpdateMe([FromBody] ProfileUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Payload is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.BankInfoSummary is not null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Bank information must be managed through the bank-info endpoint." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.Include(u => u.PostalAddress)
|
||||||
|
.Include(u => u.BankInfo)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == uid);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.UserName))
|
||||||
|
{
|
||||||
|
var trimmedUserName = request.UserName.Trim();
|
||||||
|
if (!string.Equals(user.UserName, trimmedUserName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var usernameUpdate = await _userManager.SetUserNameAsync(user, trimmedUserName);
|
||||||
|
if (!usernameUpdate.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Unable to update username.", details = usernameUpdate.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.FullName is not null)
|
||||||
|
{
|
||||||
|
user.FullName = request.FullName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Address is not null)
|
||||||
|
{
|
||||||
|
var trimmedAddress = request.Address.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedAddress))
|
||||||
|
{
|
||||||
|
user.PostalAddress = null;
|
||||||
|
}
|
||||||
|
else if (user.PostalAddress is null)
|
||||||
|
{
|
||||||
|
user.PostalAddress = new Yavsc.Models.Relationship.Location { Address = trimmedAddress };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
user.PostalAddress.Address = trimmedAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.GoogleCalendarId is not null)
|
||||||
|
{
|
||||||
|
user.DedicatedGoogleCalendar = request.GoogleCalendarId.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(uid);
|
||||||
|
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("preferences/monthly-email")]
|
||||||
|
public async Task<IActionResult> UpdateMonthlyEmailPreference([FromBody] MonthlyEmailPreferenceRequest request)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
user.AllowMonthlyEmail = request.Enabled;
|
||||||
|
var result = await _userManager.UpdateAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Unable to update email preferences.", details = result.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { enabled = user.AllowMonthlyEmail });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("security/two-factor")]
|
||||||
|
public async Task<IActionResult> UpdateTwoFactor([FromBody] TwoFactorRequest request)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userManager.SetTwoFactorEnabledAsync(user, request.Enabled);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Unable to update two-factor authentication.", details = result.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { enabled = user.TwoFactorEnabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("security/password")]
|
||||||
|
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
if (request is null
|
||||||
|
|| string.IsNullOrWhiteSpace(request.NewPassword)
|
||||||
|
|| !string.Equals(request.NewPassword, request.ConfirmPassword, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "A matching new password and confirmation are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userManager.HasPasswordAsync(user)
|
||||||
|
? await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword)
|
||||||
|
: await _userManager.AddPasswordAsync(user, request.NewPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Unable to change the password.", details = result.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("security/external-logins")]
|
||||||
|
public async Task<IActionResult> RemoveExternalLogin([FromQuery] string provider, [FromQuery] string providerKey)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(providerKey))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "A provider and provider key are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _userManager.GetLoginsAsync(user) is { Count: <= 1 }
|
||||||
|
&& !await _userManager.HasPasswordAsync(user))
|
||||||
|
{
|
||||||
|
return Conflict(new { error = "Add a password before removing the only sign-in method." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _userManager.RemoveLoginAsync(user, provider, providerKey);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Unable to remove the external login.", details = result.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("me")]
|
||||||
|
public async Task<IActionResult> DeleteMyAccount([FromBody] DeleteAccountRequest request)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(request?.UsernameConfirmation, user.UserName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Type your exact username to confirm account deletion." });
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbContext.DeviceDeclaration.RemoveRange(
|
||||||
|
_dbContext.DeviceDeclaration.Where(device => device.DeviceOwnerId == uid));
|
||||||
|
var result = await _userManager.DeleteAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Unable to delete this account.", details = result.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("email/confirmation")]
|
||||||
|
public async Task<IActionResult> SendEmailConfirmation()
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.EmailConfirmed)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = "This email address is already confirmed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(user.Email))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "No email address is associated with this account." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||||
|
var confirmationUrl = new Uri(
|
||||||
|
new Uri(siteSettings.ExternalUrl.TrimEnd('/') + "/", UriKind.Absolute),
|
||||||
|
"Account/ConfirmEmail").ToString();
|
||||||
|
confirmationUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(
|
||||||
|
confirmationUrl,
|
||||||
|
new Dictionary<string, string?> { ["userId"] = user.Id, ["code"] = code });
|
||||||
|
|
||||||
|
await _emailSender.SendEmailAsync(
|
||||||
|
user.UserName ?? user.Email,
|
||||||
|
user.Email,
|
||||||
|
$"[{siteSettings.Title}] Confirmation de votre adresse e-mail",
|
||||||
|
$"<p>Confirmez votre adresse e-mail en suivant ce lien :</p><p><a href=\"{System.Text.Encodings.Web.HtmlEncoder.Default.Encode(confirmationUrl)}\">Confirmer mon adresse</a></p>");
|
||||||
|
|
||||||
|
return Ok(new { message = "Un e-mail de confirmation a été envoyé." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("calendars")]
|
||||||
|
public async Task<IActionResult> GetCalendars([FromQuery] string? pageToken = null)
|
||||||
|
{
|
||||||
|
var calendars = await _calendarManager.GetCalendarsAsync(pageToken ?? string.Empty);
|
||||||
|
var options = (calendars.Items ?? [])
|
||||||
|
.Where(calendar => string.Equals(calendar.AccessRole, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(calendar => new CalendarOption
|
||||||
|
{
|
||||||
|
Id = calendar.Id,
|
||||||
|
Name = calendar.Summary ?? calendar.Id
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
return Ok(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("bank-info")]
|
||||||
|
public async Task<IActionResult> GetBankInfo()
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts = await _dbContext.BankIdentity
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(bank => bank.UserId == uid)
|
||||||
|
.Select(bank => new BankAccountInfo
|
||||||
|
{
|
||||||
|
Id = bank.Id,
|
||||||
|
IBAN = bank.IBAN,
|
||||||
|
BIC = bank.BIC,
|
||||||
|
BankCode = bank.BankCode,
|
||||||
|
WicketCode = bank.WicketCode,
|
||||||
|
AccountNumber = bank.AccountNumber,
|
||||||
|
BankedKey = bank.BankedKey
|
||||||
|
})
|
||||||
|
.ToArrayAsync();
|
||||||
|
return Ok(accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("bank-info")]
|
||||||
|
public async Task<IActionResult> AddBankInfo([FromBody] BankAccountInfoRequest request)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidBankAccountRequest(request))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Provide an IBAN or BIC and ensure each bank field fits its allowed length." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bank = new Yavsc.Models.Bank.BankIdentity
|
||||||
|
{
|
||||||
|
UserId = uid,
|
||||||
|
User = user,
|
||||||
|
IBAN = request.IBAN?.Trim() ?? string.Empty,
|
||||||
|
BIC = request.BIC?.Trim() ?? string.Empty,
|
||||||
|
BankCode = request.BankCode?.Trim() ?? string.Empty,
|
||||||
|
WicketCode = request.WicketCode?.Trim() ?? string.Empty,
|
||||||
|
AccountNumber = request.AccountNumber?.Trim() ?? string.Empty,
|
||||||
|
BankedKey = request.BankedKey
|
||||||
|
};
|
||||||
|
_dbContext.BankIdentity.Add(bank);
|
||||||
|
await _dbContext.SaveChangesAsync(uid);
|
||||||
|
return Ok(new BankAccountInfo
|
||||||
|
{
|
||||||
|
Id = bank.Id,
|
||||||
|
IBAN = bank.IBAN,
|
||||||
|
BIC = bank.BIC,
|
||||||
|
BankCode = bank.BankCode,
|
||||||
|
WicketCode = bank.WicketCode,
|
||||||
|
AccountNumber = bank.AccountNumber,
|
||||||
|
BankedKey = bank.BankedKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("bank-info/{id:long}")]
|
||||||
|
public async Task<IActionResult> UpdateBankInfo(long id, [FromBody] BankAccountInfoRequest request)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidBankAccountRequest(request))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Provide an IBAN or BIC and ensure each bank field fits its allowed length." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var bank = await _dbContext.BankIdentity
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == id && item.UserId == uid);
|
||||||
|
if (bank is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
bank.IBAN = request.IBAN?.Trim() ?? string.Empty;
|
||||||
|
bank.BIC = request.BIC?.Trim() ?? string.Empty;
|
||||||
|
bank.BankCode = request.BankCode?.Trim() ?? string.Empty;
|
||||||
|
bank.WicketCode = request.WicketCode?.Trim() ?? string.Empty;
|
||||||
|
bank.AccountNumber = request.AccountNumber?.Trim() ?? string.Empty;
|
||||||
|
bank.BankedKey = request.BankedKey;
|
||||||
|
await _dbContext.SaveChangesAsync(uid);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidBankAccountRequest(BankAccountInfoRequest? request) =>
|
||||||
|
request is not null
|
||||||
|
&& (!string.IsNullOrWhiteSpace(request.IBAN) || !string.IsNullOrWhiteSpace(request.BIC))
|
||||||
|
&& (request.IBAN?.Length ?? 0) <= 33
|
||||||
|
&& (request.BIC?.Length ?? 0) <= 15
|
||||||
|
&& (request.BankCode?.Length ?? 0) <= 5
|
||||||
|
&& (request.WicketCode?.Length ?? 0) <= 5
|
||||||
|
&& (request.AccountNumber?.Length ?? 0) <= 15
|
||||||
|
&& request.BankedKey >= 0;
|
||||||
|
|
||||||
|
[HttpDelete("bank-info/{id:long}")]
|
||||||
|
public async Task<IActionResult> DeleteBankInfo(long id)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bank = await _dbContext.BankIdentity
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == id && item.UserId == uid);
|
||||||
|
if (bank is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbContext.BankIdentity.Remove(bank);
|
||||||
|
await _dbContext.SaveChangesAsync(uid);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("performer-profile")]
|
||||||
|
public async Task<IActionResult> GetPerformerProfile()
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await _dbContext.Performers
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(p => p.PerformerId == uid);
|
||||||
|
|
||||||
|
var selectedActivityCodes = await _dbContext.UserActivities
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == uid)
|
||||||
|
.Select(x => x.DoesCode)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new PerformerProfileSettings
|
||||||
|
{
|
||||||
|
PerformerId = uid,
|
||||||
|
UserName = User.Identity?.Name ?? string.Empty,
|
||||||
|
ExerciseCountryCode = profile?.ExerciseCountryCode ?? "fr",
|
||||||
|
SIREN = profile?.SIREN ?? string.Empty,
|
||||||
|
WebSite = profile?.WebSite ?? string.Empty,
|
||||||
|
Active = profile?.Active ?? false,
|
||||||
|
AcceptNotifications = profile?.AcceptNotifications ?? false,
|
||||||
|
AcceptPublicContact = profile?.AcceptPublicContact ?? false,
|
||||||
|
UseGeoLocalizationToReduceDistanceWithClients = profile?.UseGeoLocalizationToReduceDistanceWithClients ?? false,
|
||||||
|
SelectedActivityCodes = selectedActivityCodes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("performer-profile")]
|
||||||
|
public async Task<IActionResult> SavePerformerProfile([FromBody] PerformerProfileSettings model)
|
||||||
|
{
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Payload is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(uid))
|
||||||
|
{
|
||||||
|
return Challenge();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(model.PerformerId))
|
||||||
|
{
|
||||||
|
model.PerformerId = uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(model.PerformerId, uid, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(model.ExerciseCountryCode))
|
||||||
|
{
|
||||||
|
model.ExerciseCountryCode = "fr";
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await _dbContext.Performers.FirstOrDefaultAsync(p => p.PerformerId == uid);
|
||||||
|
if (profile is null)
|
||||||
|
{
|
||||||
|
profile = new Yavsc.Models.Workflow.PerformerProfile
|
||||||
|
{
|
||||||
|
PerformerId = uid,
|
||||||
|
Performer = user,
|
||||||
|
ExerciseCountryCode = model.ExerciseCountryCode,
|
||||||
|
SIREN = model.SIREN ?? string.Empty,
|
||||||
|
WebSite = model.WebSite ?? string.Empty,
|
||||||
|
Active = model.Active,
|
||||||
|
AcceptNotifications = model.AcceptNotifications,
|
||||||
|
AcceptPublicContact = model.AcceptPublicContact,
|
||||||
|
UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients,
|
||||||
|
OrganizationAddress = new Yavsc.Models.Relationship.Location()
|
||||||
|
};
|
||||||
|
_dbContext.Performers.Add(profile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
profile.ExerciseCountryCode = model.ExerciseCountryCode;
|
||||||
|
profile.SIREN = model.SIREN ?? string.Empty;
|
||||||
|
profile.WebSite = model.WebSite ?? string.Empty;
|
||||||
|
profile.Active = model.Active;
|
||||||
|
profile.AcceptNotifications = model.AcceptNotifications;
|
||||||
|
profile.AcceptPublicContact = model.AcceptPublicContact;
|
||||||
|
profile.UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredCodes = (model.SelectedActivityCodes ?? [])
|
||||||
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existingLinks = await _dbContext.UserActivities
|
||||||
|
.Where(x => x.UserId == uid)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var link in existingLinks)
|
||||||
|
{
|
||||||
|
if (!desiredCodes.Contains(link.DoesCode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_dbContext.UserActivities.Remove(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var code in desiredCodes)
|
||||||
|
{
|
||||||
|
if (existingLinks.Any(x => string.Equals(x.DoesCode, code, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity = await _dbContext.Activities
|
||||||
|
.FirstOrDefaultAsync(a => a.Code == code);
|
||||||
|
|
||||||
|
if (activity is not null)
|
||||||
|
{
|
||||||
|
_dbContext.UserActivities.Add(new Yavsc.Models.Workflow.UserActivity
|
||||||
|
{
|
||||||
|
UserId = uid,
|
||||||
|
DoesCode = activity.Code,
|
||||||
|
Weight = 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(uid);
|
||||||
|
|
||||||
|
if (!User.IsInRole("Performer"))
|
||||||
|
{
|
||||||
|
var roleResult = await _userManager.AddToRoleAsync(user, "Performer");
|
||||||
|
if (!roleResult.Succeeded)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Performer role could not be assigned.", details = roleResult.Errors.Select(e => e.Description) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { status = "saved", performer = model });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("myhost")]
|
[HttpGet("myhost")]
|
||||||
public IActionResult MyHost ()
|
public IActionResult MyHost ()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,49 @@ namespace Yavsc.Controllers
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25)
|
public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25)
|
||||||
{
|
{
|
||||||
return _context.Users.Skip(skip).Take(take)
|
return _context.Users
|
||||||
.Select(u=> new UserInfo{
|
.Include(u => u.PostalAddress)
|
||||||
UserId = u.Id,
|
.Include(u => u.BankInfo)
|
||||||
UserName = u.UserName,
|
.Skip(skip).Take(take)
|
||||||
Avatar = u.Avatar});
|
.Select(u => new UserInfo
|
||||||
|
{
|
||||||
|
UserId = u.Id,
|
||||||
|
UserName = u.UserName,
|
||||||
|
FullName = u.FullName,
|
||||||
|
Email = u.Email,
|
||||||
|
Address = u.PostalAddress != null ? u.PostalAddress.Address : string.Empty,
|
||||||
|
Avatar = u.Avatar,
|
||||||
|
DedicatedGoogleCalendar = u.DedicatedGoogleCalendar,
|
||||||
|
BankInfoSummary = u.BankInfo != null && u.BankInfo.Count > 0
|
||||||
|
? string.Join(" | ", u.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
|
||||||
|
: string.Empty
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("search/{pattern}")]
|
[HttpGet("search/{pattern}")]
|
||||||
public IEnumerable<UserInfo> SearchApplicationUser(string pattern, int skip=0, int take = 25)
|
public IEnumerable<UserInfo> SearchApplicationUser(string pattern, int skip=0, int take = 25)
|
||||||
{
|
{
|
||||||
return _context.Users.Where(u => u.UserName.Contains(pattern))
|
var normalizedPattern = pattern ?? string.Empty;
|
||||||
.Skip(skip).Take(take)
|
return _context.Users
|
||||||
.Select(u=> new UserInfo {
|
.Include(u => u.PostalAddress)
|
||||||
UserId = u.Id,
|
.Include(u => u.BankInfo)
|
||||||
UserName = u.UserName,
|
.Where(u => u.UserName.Contains(normalizedPattern)
|
||||||
Avatar = u.Avatar });
|
|| (u.FullName != null && u.FullName.Contains(normalizedPattern))
|
||||||
|
|| (u.Email != null && u.Email.Contains(normalizedPattern)))
|
||||||
|
.Skip(skip).Take(take)
|
||||||
|
.Select(u => new UserInfo
|
||||||
|
{
|
||||||
|
UserId = u.Id,
|
||||||
|
UserName = u.UserName,
|
||||||
|
FullName = u.FullName,
|
||||||
|
Email = u.Email,
|
||||||
|
Address = u.PostalAddress != null ? u.PostalAddress.Address : string.Empty,
|
||||||
|
Avatar = u.Avatar,
|
||||||
|
DedicatedGoogleCalendar = u.DedicatedGoogleCalendar,
|
||||||
|
BankInfoSummary = u.BankInfo != null && u.BankInfo.Count > 0
|
||||||
|
? string.Join(" | ", u.BankInfo.Select(b => string.IsNullOrWhiteSpace(b.IBAN) ? b.BIC : $"{b.IBAN} / {b.BIC}"))
|
||||||
|
: string.Empty
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/ApplicationUserApi/5
|
// GET: api/ApplicationUserApi/5
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
|
||||||
using Anthropic.SDK;
|
using Anthropic.SDK;
|
||||||
using IdentityModel;
|
using IdentityModel;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Yavsc.Abstract.Interfaces;
|
using Yavsc.Abstract.Interfaces;
|
||||||
|
|
@ -54,6 +55,13 @@ internal class Program
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.RequireClaim(JwtClaimTypes.Scope, new string[] { "api" });
|
.RequireClaim(JwtClaimTypes.Scope, new string[] { "api" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
options.AddPolicy("AdministratorOnly", policy =>
|
||||||
|
{
|
||||||
|
policy
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.AddYavscCors(builder.Configuration)
|
.AddYavscCors(builder.Configuration)
|
||||||
.AddControllersWithViews();
|
.AddControllersWithViews();
|
||||||
|
|
@ -79,6 +87,16 @@ internal class Program
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString(
|
options.UseNpgsql(builder.Configuration.GetConnectionString(
|
||||||
Yavsc.Constants.YavscConnectionStringName)));
|
Yavsc.Constants.YavscConnectionStringName)));
|
||||||
|
|
||||||
|
services.AddIdentityCore<ApplicationUser>(options =>
|
||||||
|
{
|
||||||
|
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.PreferredUserName;
|
||||||
|
options.ClaimsIdentity.RoleClaimType = Constants.RoleClaimType;
|
||||||
|
})
|
||||||
|
.AddRoles<IdentityRole>()
|
||||||
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
|
.AddDefaultTokenProviders()
|
||||||
|
.AddSignInManager();
|
||||||
|
|
||||||
services.AddLocalization(options =>
|
services.AddLocalization(options =>
|
||||||
{
|
{
|
||||||
options.ResourcesPath = "Resources";
|
options.ResourcesPath = "Resources";
|
||||||
|
|
@ -116,6 +134,7 @@ internal class Program
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
|
|
||||||
|
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
||||||
app
|
app
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
.UseAuthentication()
|
.UseAuthentication()
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{*subdir}")]
|
[HttpPost("{*subdir}")]
|
||||||
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
|
public async Task<IActionResult> Post([ValidRemoteUserFilePath] string subdir="")
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
||||||
string destDir = null;
|
string destDir = null;
|
||||||
|
|
@ -159,6 +159,14 @@ namespace Yavsc.Blogs.Controllers
|
||||||
item.Length = f.Length;
|
item.Length = f.Length;
|
||||||
item.ContentType = f.ContentType;
|
item.ContentType = f.ContentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
dbContext,
|
||||||
|
uid,
|
||||||
|
user.UserName,
|
||||||
|
storageBytes: item.Length,
|
||||||
|
actorUserId: uid);
|
||||||
|
|
||||||
received.Add(item);
|
received.Add(item);
|
||||||
_logger.LogInformation($"Received '{item.FileName}'.");
|
_logger.LogInformation($"Received '{item.FileName}'.");
|
||||||
if (item.QuotaOffense)
|
if (item.QuotaOffense)
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ internal class Program
|
||||||
.AddSupportedCultures(supportedCultures)
|
.AddSupportedCultures(supportedCultures)
|
||||||
.AddSupportedUICultures(supportedCultures);
|
.AddSupportedUICultures(supportedCultures);
|
||||||
})
|
})
|
||||||
.Configure<SiteSettings>(settings =>
|
.Configure<SiteSettings>(settings =>
|
||||||
{
|
{
|
||||||
if (settings.Blog == null)
|
if (settings.Blog == null)
|
||||||
{
|
{
|
||||||
|
|
@ -98,6 +98,7 @@ internal class Program
|
||||||
{
|
{
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
|
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
||||||
app
|
app
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
.UseAuthentication()
|
.UseAuthentication()
|
||||||
|
|
|
||||||
4859
src/Yavsc.Org/Migrations/20261007011348_ResourceUsageRecords.Designer.cs
generated
Normal file
4859
src/Yavsc.Org/Migrations/20261007011348_ResourceUsageRecords.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,53 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Yavsc.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ResourceUsageRecords : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ResourceUsageRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
RecordedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ApiCalls = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
CpuSeconds = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
BandwidthMb = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
StorageMb = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
EstimatedCompensation = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false, defaultValue: "EUR"),
|
||||||
|
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UserCreated = table.Column<string>(type: "text", nullable: true),
|
||||||
|
DateModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UserModified = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ResourceUsageRecords", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ResourceUsageRecords_UserId_RecordedAtUtc",
|
||||||
|
table: "ResourceUsageRecords",
|
||||||
|
columns: new[] { "UserId", "RecordedAtUtc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ResourceUsageRecords");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2821,6 +2821,68 @@ namespace Yavsc.Migrations
|
||||||
b.ToTable("Tags");
|
b.ToTable("Tags");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Yavsc.Models.ResourceUsageRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("ApiCalls")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<decimal>("BandwidthMb")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<decimal>("CpuSeconds")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)")
|
||||||
|
.HasDefaultValue("EUR");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateCreated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateModified")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("EstimatedCompensation")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RecordedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("StorageMb")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<string>("UserCreated")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("UserModified")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "RecordedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("ResourceUsageRecords");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.Skill", b =>
|
modelBuilder.Entity("Yavsc.Models.Skill", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
|
|
||||||
54
src/Yavsc.Server/Helpers/ResourceUsageTelemetryMiddleware.cs
Normal file
54
src/Yavsc.Server/Helpers/ResourceUsageTelemetryMiddleware.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#nullable enable
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Models;
|
||||||
|
|
||||||
|
namespace Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
public sealed class ResourceUsageTelemetryMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
|
public ResourceUsageTelemetryMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
IServiceScopeFactory scopeFactory)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var userId = context.User.GetUserId();
|
||||||
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userName = context.User.Identity?.Name ?? userId;
|
||||||
|
var requestBytes = context.Request.ContentLength ?? 0L;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
var responseBytes = context.Response.ContentLength ?? 0L;
|
||||||
|
var totalBytes = requestBytes + responseBytes;
|
||||||
|
var bandwidthMb = totalBytes <= 0 ? 0m : (decimal)totalBytes / (1024m * 1024m);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
apiCalls: 1m,
|
||||||
|
bandwidthMb: bandwidthMb,
|
||||||
|
actorUserId: userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/Yavsc.Server/Helpers/ResourceUsageTracker.cs
Normal file
69
src/Yavsc.Server/Helpers/ResourceUsageTracker.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#nullable enable
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Models;
|
||||||
|
|
||||||
|
namespace Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
public static class ResourceUsageTracker
|
||||||
|
{
|
||||||
|
public static async Task RecordAsync(
|
||||||
|
ApplicationDbContext dbContext,
|
||||||
|
string userId,
|
||||||
|
string userName,
|
||||||
|
decimal cpuSeconds = 0m,
|
||||||
|
long storageBytes = 0,
|
||||||
|
decimal apiCalls = 0m,
|
||||||
|
decimal bandwidthMb = 0m,
|
||||||
|
string? actorUserId = null,
|
||||||
|
string currency = Constants.ResourceUsageDefaultCurrency)
|
||||||
|
{
|
||||||
|
if (dbContext is null || string.IsNullOrWhiteSpace(userId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveActor = string.IsNullOrWhiteSpace(actorUserId) ? userId : actorUserId;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var storageMb = storageBytes <= 0 ? 0m : (decimal)storageBytes / (1024m * 1024m);
|
||||||
|
|
||||||
|
var record = await dbContext.ResourceUsageRecords
|
||||||
|
.Where(r => r.UserId == userId)
|
||||||
|
.OrderByDescending(r => r.RecordedAtUtc)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
record = new ResourceUsageRecord
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
UserName = string.IsNullOrWhiteSpace(userName) ? userId : userName,
|
||||||
|
RecordedAtUtc = now,
|
||||||
|
ApiCalls = apiCalls,
|
||||||
|
CpuSeconds = cpuSeconds,
|
||||||
|
BandwidthMb = bandwidthMb,
|
||||||
|
StorageMb = storageMb,
|
||||||
|
EstimatedCompensation = 0m,
|
||||||
|
Currency = currency,
|
||||||
|
UserCreated = effectiveActor,
|
||||||
|
UserModified = effectiveActor,
|
||||||
|
DateCreated = now,
|
||||||
|
DateModified = now,
|
||||||
|
};
|
||||||
|
dbContext.ResourceUsageRecords.Add(record);
|
||||||
|
await dbContext.SaveChangesAsync(effectiveActor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.UserName = string.IsNullOrWhiteSpace(userName) ? record.UserName : userName;
|
||||||
|
record.RecordedAtUtc = now;
|
||||||
|
record.ApiCalls += apiCalls;
|
||||||
|
record.CpuSeconds += cpuSeconds;
|
||||||
|
record.BandwidthMb += bandwidthMb;
|
||||||
|
record.StorageMb += storageMb;
|
||||||
|
record.Currency = string.IsNullOrWhiteSpace(currency) ? record.Currency : currency;
|
||||||
|
record.DateModified = now;
|
||||||
|
record.UserModified = effectiveActor;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(effectiveActor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,14 @@ namespace Yavsc.Models
|
||||||
}
|
}
|
||||||
builder.UseIdentityByDefaultColumns();
|
builder.UseIdentityByDefaultColumns();
|
||||||
|
|
||||||
|
builder.Entity<ResourceUsageRecord>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.HasIndex(r => new { r.UserId, r.RecordedAtUtc });
|
||||||
|
entity.Property(r => r.Currency).HasMaxLength(8).HasDefaultValue(Constants.ResourceUsageDefaultCurrency);
|
||||||
|
entity.Property(r => r.UserName).HasMaxLength(256);
|
||||||
|
});
|
||||||
|
|
||||||
// Customize the ASP.NET Identity model and override the defaults if needed.
|
// Customize the ASP.NET Identity model and override the defaults if needed.
|
||||||
// For example, you can rename the ASP.NET Identity table names and more.
|
// For example, you can rename the ASP.NET Identity table names and more.
|
||||||
// Add your customizations after calling base.OnModelCreating(builder);
|
// Add your customizations after calling base.OnModelCreating(builder);
|
||||||
|
|
@ -324,6 +332,7 @@ namespace Yavsc.Models
|
||||||
|
|
||||||
public DbSet<Estimate> Estimates { get; set; }
|
public DbSet<Estimate> Estimates { get; set; }
|
||||||
public DbSet<Signature> Signatures { get; set; }
|
public DbSet<Signature> Signatures { get; set; }
|
||||||
|
public DbSet<ResourceUsageRecord> ResourceUsageRecords { get; set; }
|
||||||
public DbSet<AccountBalance> BankStatus { get; set; }
|
public DbSet<AccountBalance> BankStatus { get; set; }
|
||||||
public DbSet<BalanceImpact> BalanceImpact { get; set; }
|
public DbSet<BalanceImpact> BalanceImpact { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
36
src/Yavsc.Server/Models/ResourceUsageRecord.cs
Normal file
36
src/Yavsc.Server/Models/ResourceUsageRecord.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Yavsc.Models;
|
||||||
|
|
||||||
|
public sealed class ResourceUsageRecord : ITrackedEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime RecordedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public decimal ApiCalls { get; set; }
|
||||||
|
public decimal CpuSeconds { get; set; }
|
||||||
|
public decimal BandwidthMb { get; set; }
|
||||||
|
public decimal StorageMb { get; set; }
|
||||||
|
public decimal EstimatedCompensation { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(8)]
|
||||||
|
public string Currency { get; set; } = Constants.ResourceUsageDefaultCurrency;
|
||||||
|
|
||||||
|
public DateTime DateCreated { get; set; } = DateTime.UtcNow;
|
||||||
|
public string UserCreated { get; set; } = string.Empty;
|
||||||
|
public DateTime DateModified { get; set; } = DateTime.UtcNow;
|
||||||
|
public string UserModified { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ using Yavsc.Abstract.Identity;
|
||||||
namespace Yavsc.Models.Auth
|
namespace Yavsc.Models.Auth
|
||||||
{
|
{
|
||||||
public class Me : IApplicationUser {
|
public class Me : IApplicationUser {
|
||||||
public Me(string userId, string userName, string email, string avatar, ILocation address, string gCalId)
|
public Me(string userId, string userName, string email, string avatar, ILocation address, string gCalId, string? fullName = null)
|
||||||
{
|
{
|
||||||
Id = userId;
|
Id = userId;
|
||||||
UserName = userName;
|
UserName = userName;
|
||||||
|
|
@ -11,10 +11,16 @@ namespace Yavsc.Models.Auth
|
||||||
Avatar = avatar;
|
Avatar = avatar;
|
||||||
PostalAddress = address;
|
PostalAddress = address;
|
||||||
DedicatedGoogleCalendar = gCalId;
|
DedicatedGoogleCalendar = gCalId;
|
||||||
|
FullName = fullName ?? string.Empty;
|
||||||
|
Address = address?.Address ?? string.Empty;
|
||||||
|
BankInfoSummary = string.Empty;
|
||||||
}
|
}
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
public string UserName { get; set; }
|
public string UserName { get; set; }
|
||||||
|
public string FullName { get; set; }
|
||||||
public string EMail { get; set; }
|
public string EMail { get; set; }
|
||||||
|
public string Address { get; set; }
|
||||||
|
public string? BankInfoSummary { get; set; }
|
||||||
public string[] Roles { get; set; }
|
public string[] Roles { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Known as profile, could point to an avatar
|
/// Known as profile, could point to an avatar
|
||||||
|
|
@ -24,18 +30,18 @@ namespace Yavsc.Models.Auth
|
||||||
|
|
||||||
public IAccountBalance AccountBalance
|
public IAccountBalance AccountBalance
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string DedicatedGoogleCalendar
|
public string DedicatedGoogleCalendar
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ILocation PostalAddress
|
public ILocation PostalAddress
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,14 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||||
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AdministratorOnly", policy =>
|
||||||
|
{
|
||||||
|
policy.RequireAuthenticatedUser();
|
||||||
|
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthentication("Bearer")
|
builder.Services.AddAuthentication("Bearer")
|
||||||
.AddJwtBearer("Bearer", options =>
|
.AddJwtBearer("Bearer", options =>
|
||||||
|
|
|
||||||
124
src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs
Normal file
124
src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Abstract.Resources;
|
||||||
|
using Yavsc.Api.Test.Fixtures;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
private readonly ApiWebServerFixture _fixture;
|
||||||
|
|
||||||
|
public ResourceUsageApiControllerTests(ApiWebServerFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient NewClient(ApiWebServerFixture fixture, string subject = "alice", string scope = "api", IEnumerable<Claim>? extraClaims = null)
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
|
||||||
|
var http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(fixture.BaseAddress)
|
||||||
|
};
|
||||||
|
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope, extraClaims: extraClaims));
|
||||||
|
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCurrentUserUsage_persists_snapshot_in_database()
|
||||||
|
{
|
||||||
|
using var http = NewClient(_fixture, "alice");
|
||||||
|
|
||||||
|
var response = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var summary = await response.Content.ReadFromJsonAsync<ResourceUsageSummary>(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(summary);
|
||||||
|
Assert.Equal("alice", summary!.UserId);
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||||
|
Assert.Contains(db.ResourceUsageRecords, r => r.UserId == "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordAsync_persists_cpu_and_storage_usage()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||||
|
|
||||||
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
db,
|
||||||
|
"alice",
|
||||||
|
"alice",
|
||||||
|
cpuSeconds: 1.5m,
|
||||||
|
storageBytes: 2 * 1024 * 1024,
|
||||||
|
actorUserId: "alice");
|
||||||
|
|
||||||
|
var record = await db.ResourceUsageRecords
|
||||||
|
.SingleAsync(r => r.UserId == "alice");
|
||||||
|
|
||||||
|
Assert.True(record.CpuSeconds >= 1.5m);
|
||||||
|
Assert.True(record.StorageMb >= 2m / 1024m / 1024m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecordAsync_persists_api_and_bandwidth_usage()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
|
||||||
|
|
||||||
|
await ResourceUsageTracker.RecordAsync(
|
||||||
|
db,
|
||||||
|
"telemetry-user",
|
||||||
|
"telemetry-user",
|
||||||
|
apiCalls: 2m,
|
||||||
|
bandwidthMb: 3.5m,
|
||||||
|
actorUserId: "telemetry-user");
|
||||||
|
|
||||||
|
var record = await db.ResourceUsageRecords
|
||||||
|
.SingleAsync(r => r.UserId == "telemetry-user");
|
||||||
|
|
||||||
|
Assert.True(record.ApiCalls >= 2m);
|
||||||
|
Assert.True(record.BandwidthMb >= 3.5m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
|
||||||
|
{
|
||||||
|
using var http = NewClient(_fixture, "alice");
|
||||||
|
var first = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||||
|
|
||||||
|
using var adminHttp = NewClient(
|
||||||
|
_fixture,
|
||||||
|
"admin-user",
|
||||||
|
"api",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName)
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await adminHttp.GetAsync("/api/v1/resource-usage/admin", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<List<ResourceUsageSummary>>(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(payload);
|
||||||
|
Assert.Contains(payload!, item => item.UserId == "alice");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue