feat(PostIt): unify moderator administration and restore Android navigation

Group users and usage in the moderator administration page; retain personal profile management and show personal usage. Allow moderators to read the corresponding API lists while preserving administrator-only mutations. Restore navigation pages after Android activity recreation. Update tests, architecture documentation and CHANGELOG for 1.0.8-rc27.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 15:56:58 +01:00
commit ba384ba4c0
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
21 changed files with 682 additions and 279 deletions

View file

@ -0,0 +1,60 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
public class ActivityNavigationTests
{
[AvaloniaFact]
public async Task Activity_recreation_restores_fresh_pages_and_back_navigation()
{
var app = Assert.IsType<App>(Application.Current);
var previousView = app.View;
var original = new MainView();
var home = new HomePage();
var draft = new Settings { SelectedLanguage = "fr" };
var editor = new ContentPage { DataContext = draft };
await original.NavRoot.PushAsync(home, null);
await original.NavRoot.PushAsync(editor, null);
app.AttachMainWindow(original);
var window = new Window();
try
{
var restored = app.CreateActivityView(new Settings());
window.Content = restored;
window.Show();
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, restored.NavRoot.NavigationStack.Count);
Assert.IsType<HomePage>(restored.NavRoot.NavigationStack[0]);
Assert.NotSame(home, restored.NavRoot.NavigationStack[0]);
Assert.NotSame(editor, restored.NavRoot.NavigationStack[1]);
Assert.Same(draft, restored.NavRoot.NavigationStack[1].DataContext);
Assert.Equal("fr", draft.SelectedLanguage);
window.Content = null;
window.Content = restored;
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, restored.NavRoot.NavigationStack.Count);
// Another activity recreation must retain the already-restored stack.
window.Content = null;
var recreatedAgain = app.CreateActivityView(new Settings());
window.Content = recreatedAgain;
Dispatcher.UIThread.RunJobs();
Assert.Equal(2, recreatedAgain.NavRoot.NavigationStack.Count);
Assert.Same(draft, recreatedAgain.NavRoot.NavigationStack[1].DataContext);
await recreatedAgain.NavRoot.PopAsync(null);
Assert.IsType<HomePage>(Assert.Single(recreatedAgain.NavRoot.NavigationStack));
}
finally
{
window.Close();
app.AttachMainWindow(previousView ?? new MainView());
}
}
}

View file

@ -0,0 +1,241 @@
using System.Net.Http;
using System.Text.Json;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class AdministrationAccessTests
{
[AvaloniaTheory]
[InlineData("Moderator", true)]
[InlineData("Administrator", false)]
[InlineData("Performer", false)]
[InlineData("moderator", false)]
[InlineData("", false)]
public async Task Home_administration_visibility_and_command_follow_the_moderator_role(string role, bool allowed)
{
await using var api = new ProfileTransport(role);
var session = new SessionStatusViewModel { Api = api };
var vm = new HomePageViewModel(api, api.Settings, session, null!);
var page = new HomePage { DataContext = vm };
var window = new Window { Content = page };
try
{
window.Show();
var button = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
button => Equals(button.Content, "Administration"));
Assert.False(button.IsVisible);
await session.RefreshAsync();
Assert.Equal(allowed, session.IsModerator);
Assert.Equal(allowed, button.IsVisible);
Assert.Equal(allowed, vm.OpenAdministration.CanExecute(null));
Assert.DoesNotContain(page.GetVisualDescendants().OfType<Button>(),
button => Equals(button.Content, "Mon usage / administration"));
session.Api = null;
session.Refresh();
Assert.False(button.IsVisible);
Assert.False(vm.OpenAdministration.CanExecute(null));
}
finally
{
window.Close();
}
}
[Fact]
public async Task Late_role_response_cannot_restore_access_after_logout()
{
await using var api = new ProfileTransport("Moderator");
var pending = new TaskCompletionSource<Me>();
api.PendingProfile = pending.Task;
var session = new SessionStatusViewModel { Api = api };
var refresh = session.RefreshAsync();
session.Api = null;
session.Refresh();
pending.SetResult(new Me { Roles = ["Moderator"] });
await refresh;
Assert.False(session.IsModerator);
}
[Fact]
public async Task Role_lookup_failure_denies_access_and_reports_the_error()
{
await using var api = new ProfileTransport("Moderator") { FailProfile = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
Assert.False(session.IsModerator);
Assert.Contains("Impossible de vérifier les droits", session.SessionLabel);
}
[Fact]
public async Task Administration_loads_users_and_usage_and_matches_by_id()
{
await using var api = new ProfileTransport("Moderator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
vm.SelectedUser = Assert.Single(vm.Users);
Assert.Equal("alice", vm.SelectedUsage?.UserName);
Assert.Equal(12m, vm.SelectedUsage?.ApiCalls);
Assert.Single(vm.UsageOverview);
Assert.Contains(api.Requests, path => path.EndsWith("/resource-usage/admin"));
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/me"));
}
[AvaloniaFact]
public async Task Administration_displays_both_tabs_and_selected_user_usage()
{
await using var api = new ProfileTransport("Moderator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
vm.SelectedUser = Assert.Single(vm.Users);
var page = new AdministrationPage { DataContext = vm };
var window = new Window { Content = page, Width = 800, Height = 600 };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
Assert.Equal(2, tabs.Items.Count);
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "Appels API : 12");
tabs.SelectedIndex = 1;
Dispatcher.UIThread.RunJobs();
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "alice-id");
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "Appels API : 12");
}
finally
{
window.Close();
}
}
[Fact]
public async Task Administration_denies_non_moderators_without_requesting_private_data()
{
await using var api = new ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
api.Requests.Clear();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
Assert.Empty(api.Requests);
Assert.Empty(vm.Users);
Assert.Contains("Moderator", vm.StatusMessage);
}
[Fact]
public async Task Administration_failure_reports_error_without_falling_back_to_personal_usage()
{
await using var api = new ProfileTransport("Moderator") { FailUsage = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
await vm.RefreshAsync();
Assert.Empty(vm.Users);
Assert.Empty(vm.UsageOverview);
Assert.Contains("indisponible", vm.StatusMessage);
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/me"));
}
[Fact]
public async Task My_profile_loads_own_profile_enables_saving_and_only_loads_personal_usage()
{
await using var api = new ProfileTransport("Performer");
var vm = new UserProfilePageViewModel(api, usageClient: api.UsageClient);
await vm.RefreshAsync();
Assert.True(vm.IsProfileLoaded);
Assert.Equal(12m, vm.ResourceUsage?.ApiCalls);
Assert.True(vm.SaveProfileCommand.CanExecute(null));
vm.FullName = "Alice Updated";
Assert.Equal("Alice Updated", vm.BuildProfileUpdateRequest().FullName);
Assert.DoesNotContain(api.Requests, path => path.EndsWith("/resource-usage/admin"));
}
[Fact]
public async Task Usage_failure_does_not_disable_profile_management()
{
await using var api = new ProfileTransport("Performer") { FailUsage = true };
var vm = new UserProfilePageViewModel(api, usageClient: api.UsageClient);
await vm.RefreshAsync();
Assert.True(vm.IsProfileLoaded);
Assert.True(vm.SaveProfileCommand.CanExecute(null));
Assert.Null(vm.ResourceUsage);
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
}
private sealed class ProfileTransport : YavscApiClient
{
private readonly string _tokenPath;
private readonly string _role;
public List<string> Requests { get; } = [];
public Task<Me>? PendingProfile { get; set; }
public bool FailProfile { get; set; }
public bool FailUsage { get; set; }
public ResourceUsageApiClient UsageClient => new(this, Settings.ApiUrl);
public ProfileTransport(string role)
: this(role, Path.Combine(Path.GetTempPath(), $"postit-admin-{Guid.NewGuid():N}.json")) { }
private ProfileTransport(string role, string path) : base(new Settings(), CreateStore(path))
{
_role = role;
_tokenPath = path;
}
private static TokenStore CreateStore(string path)
{
var store = new TokenStore(path);
store.Save(new RefreshTokenRecord("access", "refresh", DateTimeOffset.UtcNow.AddHours(1), null));
return store;
}
public override async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Requests.Add(path);
object result;
if (path.EndsWith("/account/me"))
{
if (FailProfile)
throw new HttpRequestException("Profile unavailable");
result = PendingProfile is not null ? await PendingProfile
: new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] };
}
else if (path.Contains("/resource-usage/"))
{
if (FailUsage)
throw new HttpRequestException("Usage unavailable");
var usage = new ResourceUsageSummary { UserId = "alice-id", UserName = "alice", ApiCalls = 12m };
result = path.EndsWith("/admin") ? new List<ResourceUsageSummary> { usage } : usage;
}
else if (path.EndsWith("/users"))
result = new List<UserInfo> { new() { UserId = "alice-id", UserName = "alice" } };
else
throw new InvalidOperationException($"Unexpected endpoint: {path}");
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
File.Delete(_tokenPath);
}
}
}

View file

@ -38,7 +38,7 @@ public class HomePageProviderFlowTests
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
Assert.False(vm.OpenAdministration.CanExecute(null));
}
[Fact]

View file

@ -1,5 +1,4 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
@ -24,7 +23,8 @@ namespace PostIt;
public partial class App : Application, ILiveView, IVMPusherApp
{
public static bool LiveMode { get; private set; }
private int _bootStarted;
private Task? _bootTask;
private bool _sessionNavigationConfigured;
public static string? CliConfigFileSpecification { get; private set; }
/// <summary>
@ -125,6 +125,14 @@ public partial class App : Application, ILiveView, IVMPusherApp
public async Task<Page> PushPageAsync(ViewModelBase viewModel)
{
var mainView = View ?? throw new InvalidOperationException("The navigation root is not initialized.");
if (OperatingSystem.IsAndroid() &&
mainView.NavRoot.NavigationStack.LastOrDefault() is { } currentPage &&
ReferenceEquals(currentPage.DataContext, viewModel))
{
return currentPage;
}
var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
if (template is null)
{
@ -148,8 +156,6 @@ public partial class App : Application, ILiveView, IVMPusherApp
page.DataContext = viewModel;
MainView? mainView = (Current as App).View;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = mainView.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
@ -171,12 +177,25 @@ public partial class App : Application, ILiveView, IVMPusherApp
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
ApplyStettings(settings);
return View!;
};
() => CreateActivityView(settings);
}
internal MainView CreateActivityView(Settings settings)
{
var pages = View?.NavRoot.NavigationStack
.Select(page => (PageType: page.GetType(), page.DataContext))
.ToArray();
var rootView = ServiceProvider!.GetRequiredService<MainView>();
var restoredPages = pages?.Select(state =>
{
// Do not reuse controls belonging to a destroyed Android activity.
var page = (Page)ActivatorUtilities.CreateInstance(ServiceProvider, state.PageType);
page.DataContext = state.DataContext;
return page;
}).ToArray();
ConfigureRootView(rootView, restoredPages);
ApplyStettings(settings);
return rootView;
}
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
@ -196,16 +215,42 @@ public partial class App : Application, ILiveView, IVMPusherApp
}
public void ConfigureRootView(MainView rootView)
=> ConfigureRootView(rootView, null);
private void ConfigureRootView(MainView rootView, Page[]? restoredPages)
{
var initialized = false;
rootView.AttachedToVisualTree += (_, _) =>
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
Dispatcher.UIThread.Post(async () =>
{
if (initialized)
return;
initialized = true;
if (restoredPages is { Length: > 0 })
{
foreach (var page in restoredPages)
await rootView.NavRoot.PushAsync(page, null);
}
else
{
await BootOnceAsync();
if (rootView.NavRoot.NavigationStack.Count == 0)
{
var home = new HomePage
{
DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>()
};
await rootView.NavRoot.PushAsync(home, null);
}
}
});
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
if (!_sessionNavigationConfigured)
{
// Remplacer Window.NavRoot par rootView.NavRoot
rootView.NavRoot.PopToRootAsync();
};
sessionStatus.LogoutCompleted += () => View!.NavRoot.PopToRootAsync();
_sessionNavigationConfigured = true;
}
if (rootView.DataContext==null)
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
@ -233,44 +278,29 @@ public partial class App : Application, ILiveView, IVMPusherApp
}
private async Task BootOnceAsync()
private Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{
return;
}
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
await BootAsync(this.ServiceProvider!, api);
return _bootTask ??= BootAsync(ServiceProvider!, api);
}
/// <summary>
/// Run once after the main window is shown: try to refresh the
/// cached OIDC tokens silently; on success, push MainPage on top
/// of HomePage so the user lands on the blog editor already
/// authenticated. On failure (refresh token rejected, no bundle
/// on disk), leave them on HomePage and the Login button is the
/// next step.
/// Refresh cached OIDC tokens once per application process.
/// Each root view initializes or restores its own navigation stack separately.
/// </summary>
private static async Task BootAsync(
IServiceProvider provider,
YavscApiClient api)
{
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh();
var homePageVm = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePageVm);
await sessionStatus.RefreshAsync();
}
/// <summary>
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// of the current navigation stack.
/// </summary>
public static async Task PushBlogsPageAsync()
{

View file

@ -50,7 +50,16 @@ public static class ServiceCollectionHelpers
// Models
services.AddSingleton<ChatViewModel>();
// SettingsPage is a singleton: there must be one and only one
// Android activities need fresh controls; retain state in singleton VMs.
void AddReusablePage<TPage>() where TPage : class
{
if (OperatingSystem.IsAndroid())
services.AddTransient<TPage>();
else
services.AddSingleton<TPage>();
}
// On other platforms SettingsPage is a singleton: one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
@ -61,12 +70,12 @@ public static class ServiceCollectionHelpers
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddSingleton<AboutPage>();
services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
AddReusablePage<SettingsPage>();
AddReusablePage<AboutPage>();
AddReusablePage<HomePage>();
AddReusablePage<SignaturePage>();
AddReusablePage<CirclesPage>();
AddReusablePage<ActivitiesPage>();
services.AddTransient<CommandFormsPage>();
services.AddTransient<ChatPage>();
services.AddTransient<RdvPage>();
@ -81,7 +90,6 @@ public static class ServiceCollectionHelpers
services.AddTransient<UserProfilePage>();
services.AddTransient<PerformerConfigurationPage>();
services.AddTransient<AdministrationPage>();
services.AddTransient<ResourceUsagePage>();
// ViewModels
services.AddSingleton(settings);

View file

@ -64,7 +64,6 @@ public class ViewLocator : IDataTemplate
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }

View file

@ -8,12 +8,21 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class AdministrationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly ResourceUsageApiClient? _usageClient;
private readonly SessionStatusViewModel? _session;
public ObservableCollection<ResourceUsageSummary> UsageOverview { get; } = new();
public ResourceUsageSummary? SelectedUsage =>
UsageOverview.FirstOrDefault(usage => usage.UserId == SelectedUser?.UserId);
[ObservableProperty]
public partial bool IsBusy { get; set; }
@ -43,9 +52,12 @@ public partial class AdministrationPageViewModel : ViewModelBase
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public AdministrationPageViewModel(YavscApiClient? api)
public AdministrationPageViewModel(YavscApiClient? api,
ResourceUsageApiClient? usageClient = null, SessionStatusViewModel? session = null)
{
_api = api;
_usageClient = usageClient;
_session = session;
}
public AdministrationPageViewModel() : this(null) { }
@ -57,10 +69,22 @@ public partial class AdministrationPageViewModel : ViewModelBase
OnPropertyChanged(nameof(FilteredUsers));
}
partial void OnSelectedUserChanged(UserInfo? value) => OnPropertyChanged(nameof(SelectedUsage));
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
Users.Clear();
UsageOverview.Clear();
SelectedUser = null;
OnPropertyChanged(nameof(FilteredUsers));
OnPropertyChanged(nameof(SelectedUsage));
if (_session?.IsModerator != true)
{
StatusMessage = "Le rôle Moderator est requis.";
return;
}
if (_api is null || _usageClient is null)
{
StatusMessage = "Client API d’administration indisponible.";
return;
@ -74,15 +98,26 @@ public partial class AdministrationPageViewModel : ViewModelBase
? 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);
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true)
?? throw new InvalidOperationException("Le serveur a renvoyé une liste d’utilisateurs vide.");
var usage = await _usageClient.GetAdminOverviewAsync().ConfigureAwait(true)
?? throw new InvalidOperationException("Le serveur a renvoyé une liste d’usages vide.");
Users.Clear();
foreach (var user in users ?? [])
if (!_session.IsModerator)
{
StatusMessage = "Le rôle Moderator est requis.";
return;
}
foreach (var user in users)
{
Users.Add(user);
}
foreach (var item in usage)
UsageOverview.Add(item);
OnPropertyChanged(nameof(FilteredUsers));
OnPropertyChanged(nameof(SelectedUsage));
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
}
catch (Exception ex)

View file

@ -72,8 +72,7 @@ public class HomePageViewModel : ViewModelBase
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsModerator);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
}
@ -83,7 +82,6 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
public IAsyncRelayCommand OpenAdministration { get; }
public IAsyncRelayCommand OpenResourceUsage { get; }
public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; }
@ -158,6 +156,13 @@ public class HomePageViewModel : ViewModelBase
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SessionStatusViewModel.IsModerator))
{
if (Dispatcher.UIThread.CheckAccess())
OpenAdministration.NotifyCanExecuteChanged();
else
Dispatcher.UIThread.Post(OpenAdministration.NotifyCanExecuteChanged);
}
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
return;
@ -302,6 +307,9 @@ public class HomePageViewModel : ViewModelBase
private async Task OpenAdministrationAsync()
{
if (!SessionStatus.IsModerator)
throw new UnauthorizedAccessException("Le rôle Moderator est requis.");
var app = (App?)Application.Current;
if (app is null)
{
@ -318,25 +326,6 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm);
}
private async Task OpenResourceUsageAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var resourceUsageClient = app.ServiceProvider?.GetRequiredService<ResourceUsageApiClient>();
if (resourceUsageClient is null)
{
throw new InvalidOperationException("Client usage indisponible.");
}
var vm = new ResourceUsagePageViewModel(resourceUsageClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -1,90 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ResourceUsagePageViewModel : ViewModelBase
{
private readonly ResourceUsageApiClient _client;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial bool IsAdminView { get; set; }
[ObservableProperty]
public partial ResourceUsageSummary? CurrentSummary { get; set; }
[ObservableProperty]
public partial ObservableCollection<ResourceUsageSummary> Overview { get; set; } = new();
public string Heading => IsAdminView ? "Vue d'administration des usages" : "Mon usage des ressources";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public ResourceUsagePageViewModel(ResourceUsageApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
Overview.Clear();
try
{
var adminOverview = await _client.GetAdminOverviewAsync().ConfigureAwait(true);
if (adminOverview is { Count: > 0 })
{
foreach (var item in adminOverview)
{
Overview.Add(item);
}
CurrentSummary = adminOverview[0];
IsAdminView = true;
return;
}
}
catch (Exception)
{
// Non-admin users are not allowed on the admin route; fall back
// to the current-user summary below.
}
var current = await _client.GetCurrentAsync().ConfigureAwait(true);
CurrentSummary = current;
if (current is not null)
{
Overview.Clear();
Overview.Add(current);
}
IsAdminView = false;
}
catch (Exception ex)
{
CurrentSummary = null;
Overview.Clear();
IsAdminView = false;
throw new InvalidOperationException($"Impossible de charger l'usage des ressources : {ex.Message}", ex);
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,4 +1,6 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -37,6 +39,11 @@ public partial class SessionStatusViewModel : ViewModelBase
[ObservableProperty]
public partial bool IsLoggedIn { get; private set; }
[ObservableProperty]
public partial bool IsModerator { get; private set; }
private int _accessVersion;
/// <summary>Inverse of <see cref="IsLoggedIn"/>, for XAML bindings
/// (the banner shows the Login button when the user is logged out).
/// Updated from <see cref="Refresh"/>.</summary>
@ -73,12 +80,38 @@ public partial class SessionStatusViewModel : ViewModelBase
public void Refresh()
{
++_accessVersion;
IsModerator = false;
var has = Api?.HasValidSession ?? false;
IsLoggedIn = has;
IsLoggedOut = !has;
SessionLabel = has ? "Connecté" : "Déconnecté";
}
public async Task RefreshAsync()
{
Refresh();
var version = _accessVersion;
var api = Api;
if (!IsLoggedIn || api is null)
return;
try
{
var endpoint = new Uri(new Uri(api.Settings.ApiUrl.TrimEnd('/') + "/"), "account/me");
var profile = await api.CallAsync<Yavsc.Abstract.Identity.Me>(HttpMethod.Get, endpoint.ToString());
if (profile is null)
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
if (version == _accessVersion)
IsModerator = profile.Roles.Contains(Yavsc.Constants.BlogModeratorGroupName, StringComparer.Ordinal);
}
catch (Exception ex)
{
if (version == _accessVersion)
SessionLabel = $"Impossible de vérifier les droits : {ex.Message}";
}
}
/// <summary>
/// Override the banner label with an error message. Used when
/// an interactive login attempt fails so the operator sees
@ -88,6 +121,8 @@ public partial class SessionStatusViewModel : ViewModelBase
/// </summary>
public void SetError(string message)
{
++_accessVersion;
IsModerator = false;
IsLoggedIn = false;
IsLoggedOut = true;
SessionLabel = message;
@ -118,7 +153,7 @@ public partial class SessionStatusViewModel : ViewModelBase
IsBusy = false;
}
Refresh();
await RefreshAsync();
if (Api.HasValidSession)
LoginSucceeded?.Invoke();
}

View file

@ -7,6 +7,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@ -14,6 +16,13 @@ public partial class UserProfilePageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
private readonly SessionStatusViewModel? _sessionStatus;
private readonly ResourceUsageApiClient? _usageClient;
[ObservableProperty]
public partial ResourceUsageSummary? ResourceUsage { get; set; }
[ObservableProperty]
public partial string? UsageError { get; set; }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveProfileCommand))]
@ -140,10 +149,12 @@ public partial class UserProfilePageViewModel : ViewModelBase
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)
public UserProfilePageViewModel(YavscApiClient? api, SessionStatusViewModel? sessionStatus = null,
ResourceUsageApiClient? usageClient = null)
{
_api = api;
_sessionStatus = sessionStatus;
_usageClient = usageClient;
}
public UserProfilePageViewModel() : this(null, null) { }
@ -162,6 +173,8 @@ public partial class UserProfilePageViewModel : ViewModelBase
IsBusy = true;
IsProfileLoaded = false;
ResourceUsage = null;
UsageError = null;
try
{
var me = await _api.CallAsync<Me>(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true);
@ -201,6 +214,18 @@ public partial class UserProfilePageViewModel : ViewModelBase
}
IsProfileLoaded = true;
StatusMessage = "Profil chargé.";
if (_usageClient is not null)
{
try
{
ResourceUsage = await _usageClient.GetCurrentAsync()
?? throw new InvalidOperationException("Le serveur a renvoyé un usage vide.");
}
catch (Exception ex)
{
UsageError = $"Impossible de charger mon usage : {ex.Message}";
}
}
}
catch (Exception ex)
{

View file

@ -1,11 +1,12 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:resources="using:Yavsc.Abstract.Resources"
x:Class="PostIt.Views.AdministrationPage"
x:DataType="vm:AdministrationPageViewModel"
Header="Administration">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Administration"
FontSize="22"
@ -24,36 +25,74 @@
TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
<ListBox ItemsSource="{Binding FilteredUsers}"
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
<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"/>
<TabControl Grid.Row="2">
<TabItem Header="Utilisateurs">
<Grid ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
<ListBox ItemsSource="{Binding FilteredUsers}"
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
<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>
<ScrollViewer Grid.Column="1">
<Border Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="8">
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
<StackPanel DataContext="{Binding SelectedUsage}" Spacing="4">
<TextBlock Text="Usage des ressources" FontWeight="Bold"/>
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
<TextBlock Text="{Binding Currency}"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
<StackPanel Spacing="8">
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
</TabItem>
<TabItem Header="Usages">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding UsageOverview}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="resources:ResourceUsageSummary">
<Border Margin="0,0,0,8" Padding="12" CornerRadius="8"
BorderBrush="#D0D7DE" BorderThickness="1">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName}" FontWeight="Bold"/>
<TextBlock Text="{Binding UserId}"/>
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
<TextBlock Text="{Binding Currency}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</ContentPage>

View file

@ -92,11 +92,7 @@
<Button Content="Administration"
Command="{Binding OpenAdministration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon usage / administration"
Command="{Binding OpenResourceUsage}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
IsVisible="{Binding SessionStatus.IsModerator}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -1,57 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:resources="using:Yavsc.Abstract.Resources"
x:Class="PostIt.Views.ResourceUsagePage"
x:DataType="vm:ResourceUsagePageViewModel"
Header="Usage des ressources">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="{Binding Heading}"
FontSize="22"
FontWeight="SemiBold"/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<StackPanel IsVisible="{Binding !IsAdminView}">
<TextBlock Text="Résumé personnel" FontWeight="Bold"/>
<StackPanel DataContext="{Binding CurrentSummary}" Spacing="4">
<TextBlock Text="{Binding UserName, StringFormat='Utilisateur : {0}'}"/>
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
</StackPanel>
</StackPanel>
<ItemsControl ItemsSource="{Binding Overview}"
IsVisible="{Binding IsAdminView}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="resources:ResourceUsageSummary">
<Border Margin="0,0,0,8"
Padding="12"
CornerRadius="8"
BorderBrush="#D0D7DE"
BorderThickness="1">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ContentPage>

View file

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class ResourceUsagePage : ContentPage
{
public ResourceUsagePage()
{
InitializeComponent();
}
}

View file

@ -4,13 +4,13 @@
xmlns:identity="using:Yavsc.Abstract.Identity"
x:Class="PostIt.Views.UserProfilePage"
x:DataType="vm:UserProfilePageViewModel"
Header="Mon compte">
Header="Mon profil">
<ScrollViewer>
<StackPanel Margin="16" Spacing="16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="4">
<TextBlock Text="Gérer votre compte"
<TextBlock Text="Mon profil"
FontSize="22"
FontWeight="SemiBold"/>
<TextBlock Text="Paramètres de votre compte"
@ -27,6 +27,21 @@
IsIndeterminate="True"
Height="8"/>
<Border Padding="16" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<TextBlock Text="Mon usage des ressources" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding UsageError}" TextWrapping="Wrap"/>
<StackPanel DataContext="{Binding ResourceUsage}" Spacing="4">
<TextBlock Text="{Binding ApiCalls, StringFormat='Appels API : {0:F0}'}"/>
<TextBlock Text="{Binding CpuSeconds, StringFormat='CPU : {0:F3} s'}"/>
<TextBlock Text="{Binding BandwidthMb, StringFormat='Bande passante : {0:F3} Mo'}"/>
<TextBlock Text="{Binding StorageMb, StringFormat='Stockage : {0:F3} Mo'}"/>
<TextBlock Text="{Binding EstimatedCompensation, StringFormat='Estimation : {0:F2}'}"/>
<TextBlock Text="{Binding Currency}"/>
</StackPanel>
</StackPanel>
</Border>
<Border Padding="16"
BorderBrush="#D0D7DE"
BorderThickness="1"

View file

@ -78,7 +78,7 @@ public sealed class ResourceUsageApiController : Controller
[HttpGet(Constants.ResourceUsageAdminRoute)]
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
{
if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(Constants.AdminGroupName) && !User.IsInRole(Constants.BlogModeratorGroupName))
{
return Forbid();
}

View file

@ -7,7 +7,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json"),Authorize("AdministratorOnly")]
[Produces("application/json"), Authorize]
[Route(Constants.APIPrefix + "/users")]
public class ApplicationUserApiController : Controller
{
@ -19,7 +19,7 @@ namespace Yavsc.Controllers
}
// GET: api/ApplicationUserApi
[HttpGet]
[HttpGet, Authorize(Roles = Constants.AdminGroupName + "," + Constants.BlogModeratorGroupName)]
public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25)
{
return _context.Users
@ -41,7 +41,7 @@ namespace Yavsc.Controllers
});
}
[HttpGet("search/{pattern}")]
[HttpGet("search/{pattern}"), Authorize(Roles = Constants.AdminGroupName + "," + Constants.BlogModeratorGroupName)]
public IEnumerable<UserInfo> SearchApplicationUser(string pattern, int skip=0, int take = 25)
{
var normalizedPattern = pattern ?? string.Empty;
@ -68,7 +68,7 @@ namespace Yavsc.Controllers
}
// GET: api/ApplicationUserApi/5
[HttpGet("{id}", Name = "GetApplicationUser")]
[HttpGet("{id}", Name = "GetApplicationUser"), Authorize("AdministratorOnly")]
public IActionResult GetApplicationUser([FromRoute] string id)
{
if (!ModelState.IsValid)
@ -87,7 +87,7 @@ namespace Yavsc.Controllers
}
// PUT: api/ApplicationUserApi/5
[HttpPut("{id}")]
[HttpPut("{id}"), Authorize("AdministratorOnly")]
public IActionResult PutApplicationUser(string id, [FromBody] ApplicationUser applicationUser)
{
if (!ModelState.IsValid)
@ -122,7 +122,7 @@ namespace Yavsc.Controllers
}
// POST: api/ApplicationUserApi
[HttpPost]
[HttpPost, Authorize("AdministratorOnly")]
public IActionResult PostApplicationUser([FromBody] ApplicationUser applicationUser)
{
if (!ModelState.IsValid)
@ -151,7 +151,7 @@ namespace Yavsc.Controllers
}
// DELETE: api/ApplicationUserApi/5
[HttpDelete("{id}")]
[HttpDelete("{id}"), Authorize("AdministratorOnly")]
public IActionResult DeleteApplicationUser(string id)
{
if (!ModelState.IsValid)

View file

@ -182,4 +182,47 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
Assert.NotNull(payload);
Assert.Contains(payload!, item => item.UserId == "alice");
}
[Theory]
[InlineData("Moderator", HttpStatusCode.OK)]
[InlineData("Administrator", HttpStatusCode.OK)]
[InlineData("Performer", HttpStatusCode.Forbidden)]
[InlineData("moderator", HttpStatusCode.Forbidden)]
[InlineData("", HttpStatusCode.Forbidden)]
public async Task Administration_read_endpoints_enforce_roles(string role, HttpStatusCode expected)
{
using var http = NewClient(_fixture, "reader", extraClaims:
[new Claim(Yavsc.Constants.RoleClaimType, role)]);
foreach (var path in new[] { "/api/v1/users", "/api/v1/users/search/alice", "/api/v1/resource-usage/admin" })
{
var response = await http.GetAsync(path, TestContext.Current.CancellationToken);
Assert.Equal(expected, response.StatusCode);
}
}
[Fact]
public async Task Moderator_cannot_access_administrator_only_user_operations()
{
using var http = NewClient(_fixture, "moderator", extraClaims:
[new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.BlogModeratorGroupName)]);
foreach (var method in new[] { HttpMethod.Get, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Post })
{
using var request = new HttpRequestMessage(method,
method == HttpMethod.Post ? "/api/v1/users" : "/api/v1/users/alice");
using var response = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task Anonymous_user_cannot_read_administration_data()
{
using var http = NewClient(_fixture);
http.DefaultRequestHeaders.Authorization = null;
foreach (var path in new[] { "/api/v1/users", "/api/v1/users/search/alice", "/api/v1/resource-usage/admin" })
{
using var response = await http.GetAsync(path, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
}