feat(admin): port site administration to API and PostIt
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ba384ba4c0
commit
04c42d9380
29 changed files with 2580 additions and 53 deletions
|
|
@ -16,12 +16,13 @@ namespace PostIt.Tests;
|
|||
public class AdministrationAccessTests
|
||||
{
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Moderator", true)]
|
||||
[InlineData("Administrator", false)]
|
||||
[InlineData("Moderator", false)]
|
||||
[InlineData("Administrator", 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)
|
||||
public async Task Home_administration_visibility_and_command_follow_the_administrator_role(string role, bool allowed)
|
||||
{
|
||||
await using var api = new ProfileTransport(role);
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
|
|
@ -35,7 +36,7 @@ public class AdministrationAccessTests
|
|||
button => Equals(button.Content, "Administration"));
|
||||
Assert.False(button.IsVisible);
|
||||
await session.RefreshAsync();
|
||||
Assert.Equal(allowed, session.IsModerator);
|
||||
Assert.Equal(allowed, session.IsAdministrator);
|
||||
Assert.Equal(allowed, button.IsVisible);
|
||||
Assert.Equal(allowed, vm.OpenAdministration.CanExecute(null));
|
||||
Assert.DoesNotContain(page.GetVisualDescendants().OfType<Button>(),
|
||||
|
|
@ -55,32 +56,32 @@ public class AdministrationAccessTests
|
|||
[Fact]
|
||||
public async Task Late_role_response_cannot_restore_access_after_logout()
|
||||
{
|
||||
await using var api = new ProfileTransport("Moderator");
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
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"] });
|
||||
pending.SetResult(new Me { Roles = ["Administrator"] });
|
||||
await refresh;
|
||||
Assert.False(session.IsModerator);
|
||||
Assert.False(session.IsAdministrator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Role_lookup_failure_denies_access_and_reports_the_error()
|
||||
{
|
||||
await using var api = new ProfileTransport("Moderator") { FailProfile = true };
|
||||
await using var api = new ProfileTransport("Administrator") { FailProfile = true };
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
Assert.False(session.IsModerator);
|
||||
Assert.False(session.IsAdministrator);
|
||||
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");
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
|
|
@ -96,7 +97,7 @@ public class AdministrationAccessTests
|
|||
[AvaloniaFact]
|
||||
public async Task Administration_displays_both_tabs_and_selected_user_usage()
|
||||
{
|
||||
await using var api = new ProfileTransport("Moderator");
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
|
|
@ -109,7 +110,7 @@ public class AdministrationAccessTests
|
|||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
|
||||
Assert.Equal(2, tabs.Items.Count);
|
||||
Assert.Equal(7, tabs.Items.Count);
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
|
||||
text => text.Text == "Appels API : 12");
|
||||
tabs.SelectedIndex = 1;
|
||||
|
|
@ -126,9 +127,9 @@ public class AdministrationAccessTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Administration_denies_non_moderators_without_requesting_private_data()
|
||||
public async Task Administration_denies_moderators_without_requesting_private_data()
|
||||
{
|
||||
await using var api = new ProfileTransport("Administrator");
|
||||
await using var api = new ProfileTransport("Moderator");
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
api.Requests.Clear();
|
||||
|
|
@ -136,13 +137,13 @@ public class AdministrationAccessTests
|
|||
await vm.RefreshAsync();
|
||||
Assert.Empty(api.Requests);
|
||||
Assert.Empty(vm.Users);
|
||||
Assert.Contains("Moderator", vm.StatusMessage);
|
||||
Assert.Contains("Administrator", 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 };
|
||||
await using var api = new ProfileTransport("Administrator") { FailUsage = true };
|
||||
var session = new SessionStatusViewModel { Api = api };
|
||||
await session.RefreshAsync();
|
||||
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
|
||||
|
|
@ -179,7 +180,7 @@ public class AdministrationAccessTests
|
|||
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
|
||||
}
|
||||
|
||||
private sealed class ProfileTransport : YavscApiClient
|
||||
internal sealed class ProfileTransport : YavscApiClient
|
||||
{
|
||||
private readonly string _tokenPath;
|
||||
private readonly string _role;
|
||||
|
|
|
|||
227
src/PostIt/PostIt.Tests/AdministrationToolsTests.cs
Normal file
227
src/PostIt/PostIt.Tests/AdministrationToolsTests.cs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using Yavsc.Abstract.Administration;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class AdministrationToolsTests
|
||||
{
|
||||
[AvaloniaTheory]
|
||||
[InlineData(400, 800)]
|
||||
[InlineData(800, 400)]
|
||||
public void All_administration_tabs_and_typed_forms_are_accessible_in_portrait_and_landscape(int width, int height)
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
var page = new AdministrationPage { DataContext = vm };
|
||||
var window = new Window { Content = page, Width = width, Height = height };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
|
||||
for (var index = 0; index < tabs.Items.Count; ++index)
|
||||
{
|
||||
var tab = Assert.IsType<TabItem>(tabs.ContainerFromIndex(index));
|
||||
var point = tab.TranslatePoint(new Point(), page);
|
||||
Assert.NotNull(point);
|
||||
Assert.True(point.Value.X >= 0 && point.Value.X + tab.Bounds.Width <= page.Bounds.Width + 0.5);
|
||||
}
|
||||
tabs.SelectedIndex = 3;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(), block => block.Text == "RequirePkce");
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<TextBox>(),
|
||||
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ClientId");
|
||||
tabs.SelectedIndex = 5;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Contains(page.GetVisualDescendants().OfType<ComboBox>(),
|
||||
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ToSend");
|
||||
}
|
||||
finally { window.Close(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_form_retains_every_scalar_field_and_validates_numeric_input()
|
||||
{
|
||||
var settings = new ClientSettings { ClientId = "postit", ClientName = "PostIt", AccessTokenLifetime = 900,
|
||||
ConsentLifetime = null, RequireConsent = true, PairWiseSubjectSalt = null };
|
||||
var fields = AdministrationFieldViewModel.From(settings);
|
||||
Assert.Equal(typeof(ClientSettings).GetProperties().Length, fields.Count);
|
||||
Assert.Equal(settings, AdministrationFieldViewModel.Build<ClientSettings>(fields));
|
||||
var lifetime = fields.Single(field => field.Name == nameof(ClientSettings.AccessTokenLifetime));
|
||||
lifetime.Text = "not-a-number";
|
||||
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
|
||||
lifetime.Text = "-1";
|
||||
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mailing_form_round_trips_enum_and_multiline_body()
|
||||
{
|
||||
var settings = new MailingTemplateSettings { Id = "template", Topic = "Topic", Body = "First\nSecond",
|
||||
ReplyToAddress = "reply@example.invalid", ToSend = MailPeriodicity.Monthly };
|
||||
var fields = AdministrationFieldViewModel.From(settings);
|
||||
Assert.Equal(settings, AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
|
||||
Assert.True(fields.Single(field => field.Name == "Body").IsMultiline);
|
||||
var periodicity = fields.Single(field => field.Name == "ToSend");
|
||||
Assert.True(periodicity.IsChoice);
|
||||
periodicity.Text = "Unknown";
|
||||
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_commands_save_typed_data_and_require_confirmation_for_delete()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport();
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1"));
|
||||
vm.ClientFields.Single(field => field.Name == "ClientId").Text = "new-client";
|
||||
await vm.SaveClientCommand.ExecuteAsync(null);
|
||||
Assert.Equal("new-client", transport.SavedClient?.ClientId);
|
||||
Assert.Equal(1, vm.SelectedClient?.Id);
|
||||
transport.Paths.Clear();
|
||||
await vm.DeleteClientCommand.ExecuteAsync(null);
|
||||
Assert.Empty(transport.Paths);
|
||||
Assert.Contains("confirmation", vm.StatusMessage);
|
||||
vm.ConfirmDestructiveAction = true;
|
||||
await vm.DeleteClientCommand.ExecuteAsync(null);
|
||||
Assert.Contains("DELETE https://example.invalid/api/v1/administration/clients/1", transport.Paths);
|
||||
Assert.False(vm.ConfirmDestructiveAction);
|
||||
Assert.Null(vm.SelectedClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Secrets_are_shown_once_and_cleared_on_selection_and_logout_denies_operations()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport();
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
|
||||
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
|
||||
await vm.AddSecretCommand.ExecuteAsync(null);
|
||||
Assert.Equal("generated-test-value", vm.NewSecretValue);
|
||||
Assert.Equal("", vm.SecretInput);
|
||||
vm.NewClientCommand.Execute(null);
|
||||
Assert.Null(vm.NewSecretValue);
|
||||
session.Api = null;
|
||||
session.Refresh();
|
||||
transport.Paths.Clear();
|
||||
await vm.AddSecretCommand.ExecuteAsync(null);
|
||||
Assert.Empty(transport.Paths);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Secret_response_received_after_logout_is_discarded()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport
|
||||
{
|
||||
PendingSecret = new TaskCompletionSource<NewClientSecret>(TaskCreationOptions.RunContinuationsAsynchronously)
|
||||
};
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
|
||||
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
|
||||
var operation = vm.AddSecretCommand.ExecuteAsync(null);
|
||||
Assert.True(vm.IsBusy);
|
||||
session.Api = null;
|
||||
session.Refresh();
|
||||
transport.PendingSecret.SetResult(new NewClientSecret(1, "must-not-be-shown", null));
|
||||
await operation;
|
||||
Assert.Null(vm.NewSecretValue);
|
||||
Assert.Empty(vm.Clients);
|
||||
Assert.Null(vm.SelectedClient);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
Assert.Equal(["POST https://example.invalid/api/v1/administration/clients/1/secrets"], transport.Paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_without_administrator_access_clears_previous_administration_data()
|
||||
{
|
||||
var vm = new AdministrationPageViewModel();
|
||||
var client = new AdministrationClient(1, new ClientSettings { ClientId = "previous-client" }, [], []);
|
||||
vm.Clients.Add(client);
|
||||
vm.SelectedClient = client;
|
||||
vm.NewSecretValue = "previous-secret";
|
||||
await vm.RefreshAsync();
|
||||
Assert.Empty(vm.Clients);
|
||||
Assert.Null(vm.SelectedClient);
|
||||
Assert.Null(vm.NewSecretValue);
|
||||
Assert.Equal("", vm.ClientFields.Single(field => field.Name == "ClientId").Text);
|
||||
Assert.Contains("Administrator", vm.StatusMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_requires_confirmation_and_uses_only_administration_host()
|
||||
{
|
||||
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
|
||||
var session = new SessionStatusViewModel { Api = profile };
|
||||
await session.RefreshAsync();
|
||||
await using var transport = new AdminTransport();
|
||||
var vm = new AdministrationPageViewModel(null, session: session,
|
||||
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => vm.ImportTransferAsync(new AdministrationTransfer()));
|
||||
Assert.Empty(transport.Paths);
|
||||
vm.ConfirmDestructiveAction = true;
|
||||
var result = await vm.ImportTransferAsync(new AdministrationTransfer());
|
||||
Assert.Equal(new ImportResult(0, 0, 0), result);
|
||||
Assert.False(vm.ConfirmDestructiveAction);
|
||||
Assert.Equal(["POST https://example.invalid/api/v1/administration/transfer"], transport.Paths);
|
||||
}
|
||||
|
||||
private sealed class AdminTransport : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<string> Paths { get; } = [];
|
||||
public ClientSettings? SavedClient { get; private set; }
|
||||
public TaskCompletionSource<NewClientSecret>? PendingSecret { get; init; }
|
||||
public async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add($"{method} {path}");
|
||||
object result;
|
||||
if (typeof(T) == typeof(AdministrationClient))
|
||||
{
|
||||
SavedClient = Assert.IsType<ClientSettings>(body);
|
||||
result = new AdministrationClient(1, SavedClient, [], []);
|
||||
}
|
||||
else if (typeof(T) == typeof(List<AdministrationClient>))
|
||||
result = new List<AdministrationClient> { new(1, SavedClient ?? new ClientSettings { ClientId = "client" }, [], []) };
|
||||
else if (typeof(T) == typeof(NewClientSecret))
|
||||
result = PendingSecret is null
|
||||
? new NewClientSecret(1, "generated-test-value", null)
|
||||
: await PendingSecret.Task;
|
||||
else if (typeof(T) == typeof(ImportResult))
|
||||
result = new ImportResult(0, 0, 0);
|
||||
else
|
||||
throw new NotSupportedException(typeof(T).Name);
|
||||
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
|
||||
}
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add($"{method} {path}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
|
|||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
|
||||
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
|
@ -103,6 +104,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(resourceUsageClient);
|
||||
services.AddSingleton(administrationClient);
|
||||
services.AddSingleton(notificationsClient);
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
|
|
|
|||
69
src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs
Normal file
69
src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class AdministrationFieldViewModel : ObservableObject
|
||||
{
|
||||
private readonly Type _type;
|
||||
private readonly bool _wasNull;
|
||||
public string Name { get; }
|
||||
public bool IsBoolean => _type == typeof(bool);
|
||||
public bool IsChoice => _type.IsEnum;
|
||||
public bool IsText => !IsBoolean && !IsChoice;
|
||||
public bool IsMultiline => Name is "Body" or "Description";
|
||||
public string[] Choices => IsChoice ? Enum.GetNames(_type) : [];
|
||||
|
||||
[ObservableProperty] public partial string Text { get; set; } = "";
|
||||
[ObservableProperty] public partial bool Checked { get; set; }
|
||||
|
||||
private AdministrationFieldViewModel(string name, Type type, object? value)
|
||||
{
|
||||
Name = name;
|
||||
_type = Nullable.GetUnderlyingType(type) ?? type;
|
||||
_wasNull = value is null;
|
||||
Checked = value is true;
|
||||
Text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AdministrationFieldViewModel> From<
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T record) where T : class
|
||||
=> typeof(T).GetProperties().Select(property =>
|
||||
new AdministrationFieldViewModel(property.Name, property.PropertyType, property.GetValue(record))).ToArray();
|
||||
|
||||
public static T Build<T>(IEnumerable<AdministrationFieldViewModel> fields) where T : class
|
||||
{
|
||||
var values = fields.ToDictionary(field => field.Name, field => field.ReadValue());
|
||||
var record = JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(values))
|
||||
?? throw new InvalidOperationException("Le formulaire est vide.");
|
||||
var errors = new List<ValidationResult>();
|
||||
if (!Validator.TryValidateObject(record, new ValidationContext(record), errors, true))
|
||||
throw new ValidationException(string.Join("; ", errors.Select(error => error.ErrorMessage)));
|
||||
return record;
|
||||
}
|
||||
|
||||
private object? ReadValue()
|
||||
{
|
||||
if (IsBoolean) return Checked;
|
||||
if (_type == typeof(string)) return _wasNull && Text.Length == 0 ? null : Text;
|
||||
if (IsChoice)
|
||||
{
|
||||
if (!Choices.Contains(Text, StringComparer.Ordinal))
|
||||
throw new ValidationException($"{Name} : choisissez une valeur valide.");
|
||||
return Text;
|
||||
}
|
||||
if (_type == typeof(int))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Text)) return null;
|
||||
if (int.TryParse(Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) return number;
|
||||
throw new ValidationException($"{Name} : un entier est requis.");
|
||||
}
|
||||
throw new InvalidOperationException($"Type de champ non pris en charge : {Name}.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Yavsc.Abstract.Administration;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class AdministrationPageViewModel
|
||||
{
|
||||
[ObservableProperty] public partial AdministrationSummary? Summary { get; set; }
|
||||
[ObservableProperty] public partial AdministrationRole? SelectedRole { get; set; }
|
||||
[ObservableProperty] public partial RoleMember? SelectedMember { get; set; }
|
||||
[ObservableProperty] public partial string EnrollUserId { get; set; } = "";
|
||||
[ObservableProperty] public partial AdministrationClient? SelectedClient { get; set; }
|
||||
[ObservableProperty] public partial AdministrationScope? SelectedScope { get; set; }
|
||||
[ObservableProperty] public partial MailingTemplateSettings? SelectedMailingTemplate { get; set; }
|
||||
[ObservableProperty] public partial ClientCollection SelectedCollection { get; set; }
|
||||
[ObservableProperty] public partial ClientEntryResponse? SelectedEntry { get; set; }
|
||||
[ObservableProperty] public partial ClientSecretInfo? SelectedSecret { get; set; }
|
||||
[ObservableProperty] public partial string EntryKey { get; set; } = "";
|
||||
[ObservableProperty] public partial string EntryValue { get; set; } = "";
|
||||
[ObservableProperty] public partial string SecretInput { get; set; } = "";
|
||||
[ObservableProperty] public partial string SecretDescription { get; set; } = "";
|
||||
[ObservableProperty] public partial string SecretExpiration { get; set; } = "";
|
||||
[ObservableProperty] public partial string? NewSecretValue { get; set; }
|
||||
[ObservableProperty] public partial bool ConfirmDestructiveAction { get; set; }
|
||||
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> ClientFields { get; set; } = [];
|
||||
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> ScopeFields { get; set; } = [];
|
||||
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> MailingFields { get; set; } = [];
|
||||
|
||||
public ObservableCollection<AdministrationRole> Roles { get; } = [];
|
||||
public ObservableCollection<RoleMember> RoleMembers { get; } = [];
|
||||
public ObservableCollection<AdministrationClient> Clients { get; } = [];
|
||||
public ObservableCollection<AdministrationScope> Scopes { get; } = [];
|
||||
public ObservableCollection<MailingTemplateSettings> MailingTemplates { get; } = [];
|
||||
public ObservableCollection<string> MailingCriteria { get; } = [];
|
||||
public ClientCollection[] Collections { get; } = Enum.GetValues<ClientCollection>();
|
||||
|
||||
private AdministrationApiClient RequireAccess()
|
||||
{
|
||||
if (_session?.IsAdministrator != true)
|
||||
throw new UnauthorizedAccessException("Le rôle Administrator est requis.");
|
||||
return _administrationClient ?? throw new InvalidOperationException("Client API d’administration indisponible.");
|
||||
}
|
||||
|
||||
private async Task LoadToolsAsync()
|
||||
{
|
||||
var client = RequireAccess();
|
||||
var summary = await client.GetSummaryAsync();
|
||||
var roles = await client.GetRolesAsync();
|
||||
var clients = await client.GetClientsAsync();
|
||||
var scopes = await client.GetScopesAsync();
|
||||
var templates = await client.GetMailingTemplatesAsync();
|
||||
var criteria = await client.GetMailingCriteriaAsync();
|
||||
RequireAccess();
|
||||
Summary = summary;
|
||||
Replace(Roles, roles);
|
||||
Replace(Clients, clients);
|
||||
Replace(Scopes, scopes);
|
||||
Replace(MailingTemplates, templates);
|
||||
Replace(MailingCriteria, criteria);
|
||||
SelectedRole = null;
|
||||
RoleMembers.Clear();
|
||||
SelectedClient = null;
|
||||
SelectedScope = null;
|
||||
SelectedMailingTemplate = null;
|
||||
}
|
||||
|
||||
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> source)
|
||||
{
|
||||
target.Clear();
|
||||
foreach (var item in source) target.Add(item);
|
||||
}
|
||||
|
||||
private async Task RunOperationAsync(Func<AdministrationApiClient, Task> operation)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
StatusMessage = "Une opération d’administration est déjà en cours.";
|
||||
return;
|
||||
}
|
||||
IsBusy = true;
|
||||
NewSecretValue = null;
|
||||
try
|
||||
{
|
||||
var client = RequireAccess();
|
||||
await operation(client);
|
||||
RequireAccess();
|
||||
StatusMessage = "Opération effectuée.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_session?.IsAdministrator != true)
|
||||
ClearAdministrationData();
|
||||
StatusMessage = $"Administration : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
SecretInput = "";
|
||||
ConfirmDestructiveAction = false;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RequireConfirmation()
|
||||
{
|
||||
if (!ConfirmDestructiveAction)
|
||||
throw new InvalidOperationException("Cochez la confirmation avant cette opération destructive.");
|
||||
}
|
||||
|
||||
partial void OnSelectedClientChanged(AdministrationClient? value)
|
||||
{
|
||||
ClientFields = AdministrationFieldViewModel.From(value?.Settings ?? new ClientSettings());
|
||||
SelectedEntry = null;
|
||||
SelectedSecret = null;
|
||||
NewSecretValue = null;
|
||||
SecretInput = "";
|
||||
ConfirmDestructiveAction = false;
|
||||
}
|
||||
|
||||
partial void OnSelectedScopeChanged(AdministrationScope? value)
|
||||
=> ScopeFields = AdministrationFieldViewModel.From(value?.Settings ?? new ApiScopeSettings());
|
||||
|
||||
partial void OnSelectedMailingTemplateChanged(MailingTemplateSettings? value)
|
||||
=> MailingFields = AdministrationFieldViewModel.From(value ?? new MailingTemplateSettings());
|
||||
|
||||
[RelayCommand]
|
||||
private void NewClient()
|
||||
{
|
||||
SelectedClient = null;
|
||||
ClientFields = AdministrationFieldViewModel.From(new ClientSettings());
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewScope()
|
||||
{
|
||||
SelectedScope = null;
|
||||
ScopeFields = AdministrationFieldViewModel.From(new ApiScopeSettings());
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewMailingTemplate()
|
||||
{
|
||||
SelectedMailingTemplate = null;
|
||||
MailingFields = AdministrationFieldViewModel.From(new MailingTemplateSettings());
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task EnsureRolesAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
await client.EnsureRolesAsync();
|
||||
await ReloadRolesAsync(client);
|
||||
});
|
||||
|
||||
private async Task ReloadRolesAsync(AdministrationApiClient client)
|
||||
{
|
||||
var selectedId = SelectedRole?.Id;
|
||||
Replace(Roles, await client.GetRolesAsync());
|
||||
SelectedRole = Roles.FirstOrDefault(role => role.Id == selectedId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task LoadRoleMembersAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
|
||||
var members = await client.GetRoleMembersAsync(role.Id);
|
||||
RequireAccess();
|
||||
Replace(RoleMembers, members);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task EnrollAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
|
||||
var id = string.IsNullOrWhiteSpace(EnrollUserId) ? SelectedUser?.UserId : EnrollUserId.Trim();
|
||||
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Sélectionnez un utilisateur ou indiquez son identifiant.");
|
||||
await client.SetRoleMembershipAsync(role.Id, id, true);
|
||||
Replace(RoleMembers, await client.GetRoleMembersAsync(role.Id));
|
||||
await ReloadRolesAsync(client);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task FireAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
|
||||
var member = SelectedMember ?? throw new InvalidOperationException("Choisissez un membre.");
|
||||
await client.SetRoleMembershipAsync(role.Id, member.Id, false);
|
||||
Replace(RoleMembers, await client.GetRoleMembersAsync(role.Id));
|
||||
await ReloadRolesAsync(client);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveClientAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var saved = await client.SaveClientAsync(SelectedClient?.Id, AdministrationFieldViewModel.Build<ClientSettings>(ClientFields));
|
||||
Replace(Clients, await client.GetClientsAsync());
|
||||
SelectedClient = Clients.Single(item => item.Id == saved.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteClientAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
await client.DeleteClientAsync(selected.Id);
|
||||
Replace(Clients, await client.GetClientsAsync());
|
||||
NewClient();
|
||||
});
|
||||
|
||||
private async Task ReloadClientAsync(AdministrationApiClient client, int id)
|
||||
{
|
||||
var clients = await client.GetClientsAsync();
|
||||
RequireAccess();
|
||||
Replace(Clients, clients);
|
||||
SelectedClient = Clients.Single(item => item.Id == id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task AddEntryAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Enregistrez d’abord le client.");
|
||||
if (string.IsNullOrWhiteSpace(EntryValue)) throw new InvalidOperationException("La valeur est requise.");
|
||||
await client.AddClientEntryAsync(selected.Id, SelectedCollection,
|
||||
new ClientEntry { Key = string.IsNullOrWhiteSpace(EntryKey) ? null : EntryKey, Value = EntryValue });
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
EntryValue = "";
|
||||
EntryKey = "";
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteEntryAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
var entry = SelectedEntry ?? throw new InvalidOperationException("Choisissez une entrée.");
|
||||
await client.DeleteClientEntryAsync(selected.Id, entry.Collection, entry.Id);
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
});
|
||||
|
||||
private ClientSecretRequest SecretRequest()
|
||||
{
|
||||
DateTimeOffset? expiration = null;
|
||||
if (!string.IsNullOrWhiteSpace(SecretExpiration))
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(SecretExpiration, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
throw new InvalidOperationException("Expiration invalide : utilisez une date ISO 8601 avec fuseau.");
|
||||
expiration = parsed;
|
||||
}
|
||||
return new() { Value = SecretInput, Description = SecretDescription, Expiration = expiration };
|
||||
}
|
||||
|
||||
private Task CreateSecretAsync(bool replace) => RunOperationAsync(async client =>
|
||||
{
|
||||
if (replace) RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
var secret = await client.CreateSecretAsync(selected.Id, SecretRequest(), replace);
|
||||
RequireAccess();
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
NewSecretValue = secret.Value;
|
||||
});
|
||||
|
||||
[RelayCommand] private Task AddSecretAsync() => CreateSecretAsync(false);
|
||||
[RelayCommand] private Task RegenerateSecretAsync() => CreateSecretAsync(true);
|
||||
[RelayCommand] private void ClearSecret() => NewSecretValue = null;
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteSecretAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
|
||||
var secret = SelectedSecret ?? throw new InvalidOperationException("Choisissez un secret.");
|
||||
await client.DeleteSecretAsync(selected.Id, secret.Id);
|
||||
await ReloadClientAsync(client, selected.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveScopeAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var saved = await client.SaveScopeAsync(SelectedScope?.Id, AdministrationFieldViewModel.Build<ApiScopeSettings>(ScopeFields));
|
||||
Replace(Scopes, await client.GetScopesAsync());
|
||||
SelectedScope = Scopes.Single(item => item.Id == saved.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteScopeAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedScope ?? throw new InvalidOperationException("Choisissez un scope.");
|
||||
await client.DeleteScopeAsync(selected.Id);
|
||||
Replace(Scopes, await client.GetScopesAsync());
|
||||
NewScope();
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveMailingTemplateAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
var saved = await client.SaveMailingTemplateAsync(SelectedMailingTemplate?.Id,
|
||||
AdministrationFieldViewModel.Build<MailingTemplateSettings>(MailingFields));
|
||||
Replace(MailingTemplates, await client.GetMailingTemplatesAsync());
|
||||
SelectedMailingTemplate = MailingTemplates.Single(item => item.Id == saved.Id);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteMailingTemplateAsync() => RunOperationAsync(async client =>
|
||||
{
|
||||
RequireConfirmation();
|
||||
var selected = SelectedMailingTemplate ?? throw new InvalidOperationException("Choisissez un modèle.");
|
||||
await client.DeleteMailingTemplateAsync(selected.Id);
|
||||
Replace(MailingTemplates, await client.GetMailingTemplatesAsync());
|
||||
NewMailingTemplate();
|
||||
});
|
||||
|
||||
public async Task<AdministrationTransfer> ExportTransferAsync()
|
||||
{
|
||||
var data = await RequireAccess().ExportAsync();
|
||||
RequireAccess();
|
||||
return data;
|
||||
}
|
||||
|
||||
public async Task<ImportResult> ImportTransferAsync(AdministrationTransfer transfer)
|
||||
{
|
||||
RequireConfirmation();
|
||||
var result = await RequireAccess().ImportAsync(transfer);
|
||||
ConfirmDestructiveAction = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ using PostIt.Services;
|
|||
using Yavsc.Abstract.Identity;
|
||||
using Yavsc.Abstract.Resources;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Abstract.Administration;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
private readonly YavscApiClient? _api;
|
||||
private readonly ResourceUsageApiClient? _usageClient;
|
||||
private readonly SessionStatusViewModel? _session;
|
||||
private readonly AdministrationApiClient? _administrationClient;
|
||||
|
||||
public ObservableCollection<ResourceUsageSummary> UsageOverview { get; } = new();
|
||||
|
||||
|
|
@ -53,11 +55,16 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||
|
||||
public AdministrationPageViewModel(YavscApiClient? api,
|
||||
ResourceUsageApiClient? usageClient = null, SessionStatusViewModel? session = null)
|
||||
ResourceUsageApiClient? usageClient = null, SessionStatusViewModel? session = null,
|
||||
AdministrationApiClient? administrationClient = null)
|
||||
{
|
||||
_api = api;
|
||||
_usageClient = usageClient;
|
||||
_session = session;
|
||||
_administrationClient = administrationClient;
|
||||
NewClient();
|
||||
NewScope();
|
||||
NewMailingTemplate();
|
||||
}
|
||||
|
||||
public AdministrationPageViewModel() : this(null) { }
|
||||
|
|
@ -74,14 +81,10 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
Users.Clear();
|
||||
UsageOverview.Clear();
|
||||
SelectedUser = null;
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
OnPropertyChanged(nameof(SelectedUsage));
|
||||
if (_session?.IsModerator != true)
|
||||
ClearAdministrationData();
|
||||
if (_session?.IsAdministrator != true)
|
||||
{
|
||||
StatusMessage = "Le rôle Moderator est requis.";
|
||||
StatusMessage = "Le rôle Administrator est requis.";
|
||||
return;
|
||||
}
|
||||
if (_api is null || _usageClient is null)
|
||||
|
|
@ -103,9 +106,9 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
var usage = await _usageClient.GetAdminOverviewAsync().ConfigureAwait(true)
|
||||
?? throw new InvalidOperationException("Le serveur a renvoyé une liste d’usages vide.");
|
||||
|
||||
if (!_session.IsModerator)
|
||||
if (!_session.IsAdministrator)
|
||||
{
|
||||
StatusMessage = "Le rôle Moderator est requis.";
|
||||
StatusMessage = "Le rôle Administrator est requis.";
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -118,12 +121,13 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
OnPropertyChanged(nameof(SelectedUsage));
|
||||
if (_administrationClient is not null)
|
||||
await LoadToolsAsync();
|
||||
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Users.Clear();
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
ClearAdministrationData();
|
||||
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
|
|
@ -131,4 +135,33 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
|||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAdministrationData()
|
||||
{
|
||||
Users.Clear();
|
||||
UsageOverview.Clear();
|
||||
SelectedUser = null;
|
||||
Summary = null;
|
||||
Roles.Clear();
|
||||
RoleMembers.Clear();
|
||||
Clients.Clear();
|
||||
Scopes.Clear();
|
||||
MailingTemplates.Clear();
|
||||
MailingCriteria.Clear();
|
||||
SelectedRole = null;
|
||||
SelectedMember = null;
|
||||
NewClient();
|
||||
NewScope();
|
||||
NewMailingTemplate();
|
||||
NewSecretValue = null;
|
||||
SecretInput = "";
|
||||
SecretDescription = "";
|
||||
SecretExpiration = "";
|
||||
EntryKey = "";
|
||||
EntryValue = "";
|
||||
EnrollUserId = "";
|
||||
ConfirmDestructiveAction = false;
|
||||
OnPropertyChanged(nameof(FilteredUsers));
|
||||
OnPropertyChanged(nameof(SelectedUsage));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ public class HomePageViewModel : ViewModelBase
|
|||
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
|
||||
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
|
||||
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsModerator);
|
||||
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
|
||||
OpenChat = new AsyncRelayCommand(OpenChatAsync);
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ public class HomePageViewModel : ViewModelBase
|
|||
|
||||
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(SessionStatusViewModel.IsModerator))
|
||||
if (e.PropertyName == nameof(SessionStatusViewModel.IsAdministrator))
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
OpenAdministration.NotifyCanExecuteChanged();
|
||||
|
|
@ -307,8 +307,8 @@ public class HomePageViewModel : ViewModelBase
|
|||
|
||||
private async Task OpenAdministrationAsync()
|
||||
{
|
||||
if (!SessionStatus.IsModerator)
|
||||
throw new UnauthorizedAccessException("Le rôle Moderator est requis.");
|
||||
if (!SessionStatus.IsAdministrator)
|
||||
throw new UnauthorizedAccessException("Le rôle Administrator est requis.");
|
||||
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
public partial bool IsLoggedIn { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsModerator { get; private set; }
|
||||
public partial bool IsAdministrator { get; private set; }
|
||||
|
||||
private int _accessVersion;
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
public void Refresh()
|
||||
{
|
||||
++_accessVersion;
|
||||
IsModerator = false;
|
||||
IsAdministrator = false;
|
||||
var has = Api?.HasValidSession ?? false;
|
||||
IsLoggedIn = has;
|
||||
IsLoggedOut = !has;
|
||||
|
|
@ -103,7 +103,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
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);
|
||||
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminGroupName, StringComparer.Ordinal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -122,7 +122,7 @@ public partial class SessionStatusViewModel : ViewModelBase
|
|||
public void SetError(string message)
|
||||
{
|
||||
++_accessVersion;
|
||||
IsModerator = false;
|
||||
IsAdministrator = false;
|
||||
IsLoggedIn = false;
|
||||
IsLoggedOut = true;
|
||||
SessionLabel = message;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,60 @@
|
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:resources="using:Yavsc.Abstract.Resources"
|
||||
xmlns:admin="using:Yavsc.Abstract.Administration"
|
||||
x:Class="PostIt.Views.AdministrationPage"
|
||||
x:DataType="vm:AdministrationPageViewModel"
|
||||
Header="Administration">
|
||||
|
||||
<ContentPage.DataTemplates>
|
||||
<DataTemplate DataType="{x:Type vm:AdministrationFieldViewModel}" x:DataType="vm:AdministrationFieldViewModel">
|
||||
<StackPanel Spacing="4" Margin="0,0,0,10">
|
||||
<TextBlock Text="{Binding Name}" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding Text, Mode=TwoWay}" IsVisible="{Binding IsText}"
|
||||
AcceptsReturn="{Binding IsMultiline}" TextWrapping="Wrap"/>
|
||||
<CheckBox IsChecked="{Binding Checked, Mode=TwoWay}" IsVisible="{Binding IsBoolean}"/>
|
||||
<ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding Text, Mode=TwoWay}"
|
||||
IsVisible="{Binding IsChoice}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:AdministrationRole}" x:DataType="admin:AdministrationRole">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
<TextBlock Text="{Binding UserCount, StringFormat='({0} membres)'}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:RoleMember}" x:DataType="admin:RoleMember">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding UserName}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Id}"/>
|
||||
<TextBlock Text="{Binding Email}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:AdministrationClient}" x:DataType="admin:AdministrationClient">
|
||||
<TextBlock Text="{Binding Settings.ClientId}"/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:AdministrationScope}" x:DataType="admin:AdministrationScope">
|
||||
<TextBlock Text="{Binding Settings.Name}"/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:MailingTemplateSettings}" x:DataType="admin:MailingTemplateSettings">
|
||||
<TextBlock Text="{Binding Id}"/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:ClientEntryResponse}" x:DataType="admin:ClientEntryResponse">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Collection}" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Key}"/>
|
||||
<TextBlock Text="{Binding Value}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type admin:ClientSecretInfo}" x:DataType="admin:ClientSecretInfo">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Description}"/>
|
||||
<TextBlock Text="{Binding Created, StringFormat='Création : {0:u}'}"/>
|
||||
<TextBlock Text="{Binding Expiration, StringFormat='Expiration : {0:u}'}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ContentPage.DataTemplates>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="12">
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="Administration"
|
||||
|
|
@ -13,6 +63,8 @@
|
|||
FontWeight="SemiBold"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="8">
|
||||
<TextBlock Text="{Binding Summary.UserCount, StringFormat='Utilisateurs enregistrés : {0}'}"/>
|
||||
<TextBlock Text="{Binding Summary.AdministratorCount, StringFormat='Administrateurs : {0}'}"/>
|
||||
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
|
||||
PlaceholderText="Rechercher un utilisateur..."/>
|
||||
<Button Content="Actualiser"
|
||||
|
|
@ -23,9 +75,16 @@
|
|||
Height="8"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"/>
|
||||
<CheckBox Content="Je confirme la prochaine suppression, exclusion, régénération ou import."
|
||||
IsChecked="{Binding ConfirmDestructiveAction, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TabControl Grid.Row="2">
|
||||
<TabControl Grid.Row="2" IsEnabled="{Binding !IsBusy}">
|
||||
<TabControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</TabControl.ItemsPanel>
|
||||
<TabItem Header="Utilisateurs">
|
||||
<Grid ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
|
||||
<ListBox ItemsSource="{Binding FilteredUsers}"
|
||||
|
|
@ -93,6 +152,108 @@
|
|||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Rôles">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Rôles et affectations" FontSize="18" FontWeight="Bold"/>
|
||||
<Button Content="Initialiser les rôles standards" Command="{Binding EnsureRolesCommand}"/>
|
||||
<ComboBox ItemsSource="{Binding Roles}" SelectedItem="{Binding SelectedRole, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<Button Content="Afficher les membres" Command="{Binding LoadRoleMembersCommand}"/>
|
||||
<ListBox ItemsSource="{Binding RoleMembers}" SelectedItem="{Binding SelectedMember, Mode=TwoWay}" Height="200"/>
|
||||
<TextBlock Text="Identifiant à inscrire (ou utilisateur sélectionné dans l’onglet Utilisateurs)" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding EnrollUserId, Mode=TwoWay}" PlaceholderText="Identifiant utilisateur"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Inscrire" Command="{Binding EnrollCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Retirer le membre sélectionné" Command="{Binding FireCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding Summary.Assembly}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding Summary.Runtime}" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Clients OIDC">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<ComboBox ItemsSource="{Binding Clients}" SelectedItem="{Binding SelectedClient, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" PlaceholderText="Choisir un client"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Nouveau" Command="{Binding NewClientCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Enregistrer" Command="{Binding SaveClientCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer" Command="{Binding DeleteClientCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<Expander Header="Paramètres du client" IsExpanded="True" HorizontalAlignment="Stretch">
|
||||
<ItemsControl ItemsSource="{Binding ClientFields}"/>
|
||||
</Expander>
|
||||
<TextBlock Text="Collections" FontSize="18" FontWeight="Bold"/>
|
||||
<ListBox ItemsSource="{Binding SelectedClient.Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}" Height="200"/>
|
||||
<ComboBox ItemsSource="{Binding Collections}" SelectedItem="{Binding SelectedCollection, Mode=TwoWay}"/>
|
||||
<TextBox Text="{Binding EntryKey, Mode=TwoWay}" PlaceholderText="Clé (claims et propriétés)"/>
|
||||
<TextBox Text="{Binding EntryValue, Mode=TwoWay}" PlaceholderText="Valeur"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Ajouter l’entrée" Command="{Binding AddEntryCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer l’entrée sélectionnée" Command="{Binding DeleteEntryCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Secrets" FontSize="18" FontWeight="Bold"/>
|
||||
<ListBox ItemsSource="{Binding SelectedClient.Secrets}" SelectedItem="{Binding SelectedSecret, Mode=TwoWay}" Height="150"/>
|
||||
<TextBox Text="{Binding SecretInput, Mode=TwoWay}" PasswordChar="●"
|
||||
PlaceholderText="Secret fourni (vide : générer un secret)"/>
|
||||
<TextBox Text="{Binding SecretDescription, Mode=TwoWay}" PlaceholderText="Description"/>
|
||||
<TextBox Text="{Binding SecretExpiration, Mode=TwoWay}" PlaceholderText="Expiration ISO 8601 (facultative)"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Ajouter un secret" Command="{Binding AddSecretCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Régénérer et remplacer tous les secrets" Command="{Binding RegenerateSecretCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer le secret sélectionné" Command="{Binding DeleteSecretCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Nouveau secret : copiez-le maintenant. Il ne sera plus consultable ensuite." TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding NewSecretValue}" IsReadOnly="True"/>
|
||||
<Button Content="Masquer le nouveau secret" Command="{Binding ClearSecretCommand}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Scopes API">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<ComboBox ItemsSource="{Binding Scopes}" SelectedItem="{Binding SelectedScope, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" PlaceholderText="Choisir un scope"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Nouveau" Command="{Binding NewScopeCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Enregistrer" Command="{Binding SaveScopeCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer" Command="{Binding DeleteScopeCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<ItemsControl ItemsSource="{Binding ScopeFields}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Courriels">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<ComboBox ItemsSource="{Binding MailingTemplates}" SelectedItem="{Binding SelectedMailingTemplate, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" PlaceholderText="Choisir un modèle"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Nouveau" Command="{Binding NewMailingTemplateCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Enregistrer" Command="{Binding SaveMailingTemplateCommand}" Margin="0,0,8,8"/>
|
||||
<Button Content="Supprimer" Command="{Binding DeleteMailingTemplateCommand}" Margin="0,0,8,8"/>
|
||||
</WrapPanel>
|
||||
<TextBlock Text="Critères de diffusion disponibles (champ Id du modèle)" TextWrapping="Wrap"/>
|
||||
<ItemsControl ItemsSource="{Binding MailingCriteria}"/>
|
||||
<ItemsControl ItemsSource="{Binding MailingFields}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="Données">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Transferts de données métier" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock Text="Comptes (données métier uniquement), billets et activités. Aucun mot de passe, jeton, rôle ou secret d’authentification n’est transféré."
|
||||
TextWrapping="Wrap"/>
|
||||
<TextBlock Text="L’import met à jour les identifiants existants et crée les nouvelles lignes, en une transaction. Les cercles référencés doivent déjà exister. Les nouveaux comptes n’ont pas de mot de passe."
|
||||
TextWrapping="Wrap"/>
|
||||
<Button Content="Exporter vers un fichier JSON" Click="Export_Click"/>
|
||||
<Button Content="Importer un fichier JSON (16 Mo maximum)" Click="Import_Click"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
using Avalonia.Controls;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc.Abstract.Administration;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
|
|
@ -7,5 +15,75 @@ public partial class AdministrationPage : ContentPage
|
|||
public AdministrationPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
DetachedFromVisualTree += (_, _) =>
|
||||
{
|
||||
if (DataContext is AdministrationPageViewModel vm)
|
||||
{
|
||||
vm.NewSecretValue = null;
|
||||
vm.SecretInput = "";
|
||||
vm.ConfirmDestructiveAction = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async void Export_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AdministrationPageViewModel vm || vm.IsBusy) return;
|
||||
vm.IsBusy = true;
|
||||
try
|
||||
{
|
||||
var data = await vm.ExportTransferAsync();
|
||||
var content = JsonSerializer.SerializeToUtf8Bytes(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
if (!await FileSaveHelpers.SaveAsync("yavsc-business-data.json", "json", content)) return;
|
||||
vm.StatusMessage = "Données métier exportées.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
vm.StatusMessage = $"Export impossible : {ex.Message}";
|
||||
}
|
||||
finally { vm.IsBusy = false; }
|
||||
}
|
||||
|
||||
private async void Import_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AdministrationPageViewModel vm || vm.IsBusy) return;
|
||||
vm.IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (!vm.ConfirmDestructiveAction) throw new InvalidOperationException("Confirmez l’import avant de choisir un fichier.");
|
||||
var topLevel = TopLevel.GetTopLevel(this) ?? throw new InvalidOperationException("Sélecteur de fichiers indisponible.");
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Importer les données métier", AllowMultiple = false,
|
||||
FileTypeFilter = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }]
|
||||
});
|
||||
if (files.Count == 0) return;
|
||||
const int limit = 16 * 1024 * 1024;
|
||||
await using var stream = await files[0].OpenReadAsync();
|
||||
using var data = new MemoryStream();
|
||||
var buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = await stream.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
if (data.Length + count > limit) throw new InvalidOperationException("Le fichier dépasse 16 Mo.");
|
||||
await data.WriteAsync(buffer.AsMemory(0, count));
|
||||
}
|
||||
data.Position = 0;
|
||||
var transfer = await JsonSerializer.DeserializeAsync<AdministrationTransfer>(data,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? throw new InvalidOperationException("Le fichier est vide.");
|
||||
var result = await vm.ImportTransferAsync(transfer);
|
||||
await vm.RefreshAsync();
|
||||
vm.StatusMessage = $"Import effectué : {result.Users} comptes, {result.Blogs} billets, {result.Activities} activités. {vm.StatusMessage}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
vm.StatusMessage = $"Import impossible : {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
vm.ConfirmDestructiveAction = false;
|
||||
vm.IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@
|
|||
<Button Content="Administration"
|
||||
Command="{Binding OpenAdministration}"
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="{Binding SessionStatus.IsModerator}"/>
|
||||
IsVisible="{Binding SessionStatus.IsAdministrator}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
|
|
|||
Loading…
Reference in a new issue