feat(admin): port site administration to API and PostIt
Some checks failed
Dotnet build and test / build (push) Failing after 13m0s
Forgejo Release / release (push) Successful in 8m1s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 18:47:12 +01:00
commit 04c42d9380
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
29 changed files with 2580 additions and 53 deletions

View file

@ -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;

View 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; }
}
}

View file

@ -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);

View 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}.");
}
}

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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)

View file

@ -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;

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -92,7 +92,7 @@
<Button Content="Administration"
Command="{Binding OpenAdministration}"
HorizontalAlignment="Center"
IsVisible="{Binding SessionStatus.IsModerator}"/>
IsVisible="{Binding SessionStatus.IsAdministrator}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>