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

@ -11,6 +11,15 @@
### Changed
* [PostIt] Une seule page Administration regroupe les utilisateurs et
leurs usages, avec le détail d’usage de l’utilisateur sélectionné.
L’accès depuis l’accueil et sa commande sont réservés au rôle `Moderator`,
vérifié via le profil du backend. « Mon profil » conserve la gestion
du compte et accueille l’usage personnel, sans consulter les usages globaux.
* [Yavsc.Api] Le rôle `Moderator` peut lire les listes d’utilisateurs et
d’usages ; les administrateurs conservent leurs droits existants et
les autres opérations sur les utilisateurs restent administrateur uniquement.
* [PostIt] La page des blogs passe en deux colonnes lorsque la largeur de
la fenêtre atteint sa hauteur : billet à gauche, recherche, commandes et
liste à droite, avec séparateur vertical redimensionnable. La disposition
@ -19,6 +28,9 @@
### Fixed
* [PostIt.Android] Restauration de la pile de navigation lors de la
recréation de l'activité après suspension : nouvelles pages liées aux
mêmes modèles de vue, sans plus de procès.
* [PostIt] Le changement de disposition de la page des blogs est différé
sur le thread UI, évitant l'exception « Visual was invalidated during
the render pass » au redimensionnement.

View file

@ -40,6 +40,40 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop).
Toute la logique (VM, services, navigation, settings, OIDC) est
dans le code-base partagé.
## Reprise Android
`IActivityApplicationLifetime.MainViewFactory` crée une nouvelle `MainView`
pour chaque activité Android. Avant de remplacer la racine, PostIt relève
les types et les modèles de vue des pages de la pile précédente, puis
recrée ces pages dans le même ordre, sans animation. Les contrôles de
l'ancienne activité ne sont pas réutilisés ; les modèles de vue conservent
les sélections et brouillons en mémoire. Une nouvelle racine sans historique
reçoit la page d'accueil, même si l'initialisation de session a déjà eu lieu.
Le rafraîchissement silencieux de session reste unique par processus.
Cette restauration couvre la recréation d'activité dans le même processus,
pas la destruction du processus par Android.
## Administration et profil personnel
L'accueil présente un seul accès **Administration**, visible et exécutable
uniquement pour le rôle exact `Moderator`. `SessionStatusViewModel.RefreshAsync`
lit les rôles dans `GET account/me` après le rafraîchissement de session ;
une erreur de lecture masque l'accès et affiche une erreur dans la bannière.
La déconnexion invalide aussi les vérifications de droits encore en vol.
`AdministrationPage` regroupe deux onglets : utilisateurs et usages globaux.
Le détail d'un utilisateur associe son usage par `UserId`, sans dépendre
du nom affiché. Les erreurs ne déclenchent pas de repli sur l'usage personnel.
Le backend autorise la lecture des listes aux rôles `Moderator` et
`Administrator`, mais garde les autres opérations utilisateurs sous la
politique `AdministratorOnly`. L'autorisation serveur reste indispensable,
indépendamment de la visibilité du bouton dans PostIt.
**Mon profil** réutilise la page de gestion du compte existante et les
endpoints `account/me` pour lire et enregistrer les informations personnelles.
Elle affiche aussi `resource-usage/me`, sans appeler la liste des usages
globaux. Une indisponibilité de l'usage n'empêche pas la gestion du profil.
## Lecture des billets et fichiers utilisateur
La page des blogs affiche par défaut une vue de lecture : titre, login

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 =
() =>
() => CreateActivityView(settings);
}
internal MainView CreateActivityView(Settings settings)
{
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
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 View!;
};
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,7 +25,9 @@
TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
<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>
@ -42,7 +45,8 @@
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
<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"/>
@ -52,8 +56,43 @@
<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>
</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);
}
}
}