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:
parent
08a3541749
commit
ba384ba4c0
21 changed files with 682 additions and 279 deletions
60
src/PostIt/PostIt.Tests/ActivityNavigationTests.cs
Normal file
60
src/PostIt/PostIt.Tests/ActivityNavigationTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
241
src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
Normal file
241
src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class ResourceUsagePage : ContentPage
|
||||
{
|
||||
public ResourceUsagePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue