diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81eba2037..86afc7514 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@
### Added
+* [PostIt, Yavsc.Api] Portage des outils des contrôleurs web
+ d’administration : tableau de bord, rôles et affectations, clients OIDC
+ (paramètres, collections et secrets), scopes API et modèles de courriels.
+ Le dernier administrateur ne peut pas être exclu ; les suppressions et
+ rotations de secrets demandent une confirmation dans PostIt.
+* [Yavsc.Api] Routes `api/v1/administration`, DTO dédiés, client
+ `AdministrationApiClient` et documentation OpenAPI protégée en développement.
+ Les secrets OIDC sont hachés en SHA-256 et seule leur création restitue
+ la valeur en clair, sans cache.
+* [PostIt, Yavsc.Api] Export et import JSON transactionnel des données
+ métier des comptes, billets et activités. Les comptes sont transférés
+ sans mots de passe, jetons, confirmations ou rôles ; les contrôles
+ d’accès des billets sont conservés et leurs références sont validées.
+
* [PostIt] Service `CameraStreamer` (préliminaire) : détection des
périphériques de capture vidéo (DirectShow sous Windows via le paquet
`DirectShowLib`, `/dev/video*` sous Linux) et création d'un média
@@ -13,12 +27,11 @@
* [PostIt] Une seule page Administration regroupe les utilisateurs et
leurs usages, avec le détail d’usage de l’utilisateur sélectionné.
- L’accès depuis l’accueil et sa commande sont réservés au rôle `Moderator`,
+ L’accès depuis l’accueil et sa commande sont désormais réservés au rôle `Administrator`,
vérifié via le profil du backend. « Mon profil » conserve la gestion
du compte et accueille l’usage personnel, sans consulter les usages globaux.
-* [Yavsc.Api] Le rôle `Moderator` peut lire les listes d’utilisateurs et
- d’usages ; les administrateurs conservent leurs droits existants et
- les autres opérations sur les utilisateurs restent administrateur uniquement.
+* [Yavsc.Api] Les listes d’utilisateurs et d’usages globaux sont désormais
+ réservées au rôle `Administrator`, comme les autres outils d’administration.
* [PostIt] La page des blogs passe en deux colonnes lorsque la largeur de
la fenêtre atteint sa hauteur : billet à gauche, recherche, commandes et
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b0b055b98..1e3577c72 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -33,6 +33,7 @@
+
diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md
index faa67ad38..2ba6cb812 100644
--- a/doc/architecture/postit.md
+++ b/doc/architecture/postit.md
@@ -56,17 +56,19 @@ pas la destruction du processus par Android.
## Administration et profil personnel
L'accueil présente un seul accès **Administration**, visible et exécutable
-uniquement pour le rôle exact `Moderator`. `SessionStatusViewModel.RefreshAsync`
+uniquement pour le rôle exact `Administrator`. `SessionStatusViewModel.RefreshAsync`
lit les rôles dans `GET account/me` après le rafraîchissement de session ;
une erreur de lecture masque l'accès et affiche une erreur dans la bannière.
La déconnexion invalide aussi les vérifications de droits encore en vol.
-`AdministrationPage` regroupe deux onglets : utilisateurs et usages globaux.
+`AdministrationPage` regroupe sept onglets : utilisateurs, usages globaux,
+rôles, clients OIDC, scopes API, courriels et données. Les en-têtes se
+répartissent sur plusieurs lignes sur les écrans étroits.
Le détail d'un utilisateur associe son usage par `UserId`, sans dépendre
du nom affiché. Les erreurs ne déclenchent pas de repli sur l'usage personnel.
-Le backend autorise la lecture des listes aux rôles `Moderator` et
-`Administrator`, mais garde les autres opérations utilisateurs sous la
-politique `AdministratorOnly`. L'autorisation serveur reste indispensable,
+Le backend réserve aussi les listes aux administrateurs, sous la
+politique `AdministratorOnly`. Le rôle `Moderator` seul n'ouvre plus
+l'administration de PostIt. L'autorisation serveur reste indispensable,
indépendamment de la visibilité du bouton dans PostIt.
**Mon profil** réutilise la page de gestion du compte existante et les
@@ -74,6 +76,68 @@ endpoints `account/me` pour lire et enregistrer les informations personnelles.
Elle affiche aussi `resource-usage/me`, sans appeler la liste des usages
globaux. Une indisponibilité de l'usage n'empêche pas la gestion du profil.
+### Outils d'administration du site
+
+Les outils portés correspondent aux contrôleurs de
+`Yavsc.Org/Controllers/Administration` ; le site web existant est conservé.
+Les nouvelles routes vivent dans Yavsc.Api sous `api/v1/administration`.
+Les données passent par des records dédiés de Yavsc.Abstract et par
+`AdministrationApiClient`, jamais par les entités EF.
+
+| Routes | Fonction |
+| --- | --- |
+| `summary`, `roles`, `roles/ensure` | Statistiques, informations du runtime, rôles standards |
+| `roles/{id}/members[/{userId}]` | Membres, inscription et exclusion |
+| `clients[/{id}]` | Paramètres scalaires des clients OAuth/OIDC |
+| `clients/{id}/collections/{collection}[/{rowId}]` | Redirect URIs, post-logout URIs, scopes, grant types, CORS, restrictions IdP, claims et propriétés |
+| `clients/{id}/secrets[/regenerate]`, `clients/{id}/secrets/{secretId}` | Ajout, rotation et suppression des secrets |
+| `scopes[/{id}]` | Création, consultation, modification et suppression des scopes API |
+| `mailing-criteria`, `mailing-templates[/{id}]` | Critères de diffusion et modèles de courriels |
+| `transfer` | Export par GET, import transactionnel par POST |
+
+Les formulaires de clients, scopes et modèles sont construits à partir
+des propriétés de leurs DTO typés, afin de conserver tous les champs de
+l'interface web. Les booléens et énumérations disposent de contrôles dédiés ;
+les valeurs numériques et annotations de validation sont vérifiées avant
+l'envoi puis à nouveau côté serveur.
+
+Un nouveau client démarre avec `authorization_code`, PKCE et les scopes
+`openid` et `profile`. Les autres types d'autorisation restent configurables
+dans ses collections. Une rotation remplace tous les secrets précédents et
+expire par défaut après 90 jours. Le serveur conserve uniquement le SHA-256
+du secret partagé ; les lectures ne rendent que les métadonnées.
+PostIt efface la valeur nouvellement créée au changement de client, à la
+prochaine opération et au détachement de la page. Une réponse de création
+de secret reçue après déconnexion est ignorée ; un rafraîchissement sans
+droits efface les données d'administration précédemment chargées. Supprimer un objet,
+exclure un membre, remplacer les secrets ou importer demande une confirmation
+explicite. Le serveur refuse l'exclusion du dernier administrateur.
+
+Le transfert JSON version 1 comprend les informations métier des comptes
+(identifiant, nom, e-mail, nom complet, avatar, calendrier et limites de
+stockage), les billets (contenu, auteur, publication, cercles et étiquettes)
+et les paramètres des activités. Ce n'est pas une sauvegarde complète de
+la base : fichiers, sessions, mots de passe, jetons, rôles, moyens de paiement,
+adresses et historique d'audit ne sont pas transférés. L'e-mail d'un compte
+existant, clé alternative du modèle actuel, ne peut pas être changé par import.
+Les nouveaux comptes n'ont ni mot de passe ni e-mail confirmé.
+
+Les identifiants des billets sont préservés et la séquence PostgreSQL est
+réajustée après l'import. Tous les auteurs et cercles doivent exister et
+les parents d'activités doivent former un graphe sans cycle. Une erreur
+annule l'ensemble de l'import ; aucun repli n'élargit les permissions d'un
+billet. Les fichiers importés et requêtes sont limités à 16 Mio ; les gros
+exports doivent être répartis en plusieurs transferts de même schéma.
+Si plusieurs étiquettes portent déjà le même nom, l'import réutilise celle
+ayant le plus petit identifiant sans supprimer les autres.
+
+L'initialisation du premier administrateur reste le mécanisme web existant,
+et n'est pas proposée aux utilisateurs non administrateurs par PostIt.
+En développement, `/openapi/administration.json` décrit uniquement ces outils,
+avec accès `AdministratorOnly`. Les requêtes de référence sont dans
+[Administration.http](../../src/Yavsc.Api/Administration.http) ; l'adresse
+locale d'exemple est celle du serveur de tests, à adapter au déploiement.
+
## Lecture des billets et fichiers utilisateur
La page des blogs affiche par défaut une vue de lecture : titre, login
diff --git a/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs b/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
index ef1466e5f..50e64f713 100644
--- a/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
+++ b/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs
@@ -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(),
@@ -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();
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());
- Assert.Equal(2, tabs.Items.Count);
+ Assert.Equal(7, tabs.Items.Count);
Assert.Contains(page.GetVisualDescendants().OfType(),
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;
diff --git a/src/PostIt/PostIt.Tests/AdministrationToolsTests.cs b/src/PostIt/PostIt.Tests/AdministrationToolsTests.cs
new file mode 100644
index 000000000..8b03dda89
--- /dev/null
+++ b/src/PostIt/PostIt.Tests/AdministrationToolsTests.cs
@@ -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());
+ for (var index = 0; index < tabs.Items.Count; ++index)
+ {
+ var tab = Assert.IsType(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(), block => block.Text == "RequirePkce");
+ Assert.Contains(page.GetVisualDescendants().OfType(),
+ box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ClientId");
+ tabs.SelectedIndex = 5;
+ Dispatcher.UIThread.RunJobs();
+ Assert.Contains(page.GetVisualDescendants().OfType(),
+ 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(fields));
+ var lifetime = fields.Single(field => field.Name == nameof(ClientSettings.AccessTokenLifetime));
+ lifetime.Text = "not-a-number";
+ Assert.Throws(() => AdministrationFieldViewModel.Build(fields));
+ lifetime.Text = "-1";
+ Assert.Throws(() => AdministrationFieldViewModel.Build(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(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(() => AdministrationFieldViewModel.Build(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(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(() => 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 Paths { get; } = [];
+ public ClientSettings? SavedClient { get; private set; }
+ public TaskCompletionSource? PendingSecret { get; init; }
+ public async Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
+ {
+ Paths.Add($"{method} {path}");
+ object result;
+ if (typeof(T) == typeof(AdministrationClient))
+ {
+ SavedClient = Assert.IsType(body);
+ result = new AdministrationClient(1, SavedClient, [], []);
+ }
+ else if (typeof(T) == typeof(List))
+ result = new List { 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(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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
+ }
+}
diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
index 10ce04d93..d6a0f6fed 100644
--- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
+++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
@@ -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(reverseGeocoding);
diff --git a/src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs b/src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs
new file mode 100644
index 000000000..a2228f416
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/AdministrationFieldViewModel.cs
@@ -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 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(IEnumerable fields) where T : class
+ {
+ var values = fields.ToDictionary(field => field.Name, field => field.ReadValue());
+ var record = JsonSerializer.Deserialize(JsonSerializer.Serialize(values))
+ ?? throw new InvalidOperationException("Le formulaire est vide.");
+ var errors = new List();
+ 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}.");
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.Tools.cs b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.Tools.cs
new file mode 100644
index 000000000..b87cbeccc
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.Tools.cs
@@ -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 ClientFields { get; set; } = [];
+ [ObservableProperty] public partial IReadOnlyList ScopeFields { get; set; } = [];
+ [ObservableProperty] public partial IReadOnlyList MailingFields { get; set; } = [];
+
+ public ObservableCollection Roles { get; } = [];
+ public ObservableCollection RoleMembers { get; } = [];
+ public ObservableCollection Clients { get; } = [];
+ public ObservableCollection Scopes { get; } = [];
+ public ObservableCollection MailingTemplates { get; } = [];
+ public ObservableCollection MailingCriteria { get; } = [];
+ public ClientCollection[] Collections { get; } = Enum.GetValues();
+
+ 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(ObservableCollection target, IEnumerable source)
+ {
+ target.Clear();
+ foreach (var item in source) target.Add(item);
+ }
+
+ private async Task RunOperationAsync(Func 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(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(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(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 ExportTransferAsync()
+ {
+ var data = await RequireAccess().ExportAsync();
+ RequireAccess();
+ return data;
+ }
+
+ public async Task ImportTransferAsync(AdministrationTransfer transfer)
+ {
+ RequireConfirmation();
+ var result = await RequireAccess().ImportAsync(transfer);
+ ConfirmDestructiveAction = false;
+ return result;
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs
index 81853f2eb..bc6787229 100644
--- a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs
@@ -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 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));
+ }
}
diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
index c62f3fff5..253ac17c0 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
@@ -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)
diff --git a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs
index 3b8507079..d71ed9988 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs
@@ -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;
diff --git a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml
index 978886e85..5adef6c54 100644
--- a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml
@@ -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">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml.cs b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml.cs
index 433bc6111..26a0f4961 100644
--- a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml.cs
@@ -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(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;
+ }
}
}
diff --git a/src/PostIt/PostIt/Views/Layout/HomePage.axaml b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
index e689c1044..3de2dbbd7 100644
--- a/src/PostIt/PostIt/Views/Layout/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
@@ -92,7 +92,7 @@
+ IsVisible="{Binding SessionStatus.IsAdministrator}"/>
diff --git a/src/Yavsc.Abstract/Administration/AdministrationDtos.cs b/src/Yavsc.Abstract/Administration/AdministrationDtos.cs
new file mode 100644
index 000000000..98551d972
--- /dev/null
+++ b/src/Yavsc.Abstract/Administration/AdministrationDtos.cs
@@ -0,0 +1,192 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace Yavsc.Abstract.Administration;
+
+/// Site administration counts and runtime information.
+public sealed record AdministrationSummary(int UserCount, int AdministratorCount, string Assembly, string Runtime);
+
+/// A role with its current membership count.
+public sealed record AdministrationRole(string Id, string Name, int UserCount);
+
+/// A user belonging to an administration role.
+public sealed record RoleMember(string Id, string UserName, string? Email);
+
+/// Access token format understood by IdentityServer.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ClientAccessTokenType { Jwt, Reference }
+
+/// Refresh token reuse policy.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ClientRefreshTokenUsage { ReUse, OneTimeOnly }
+
+/// Refresh token expiration policy.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ClientRefreshTokenExpiration { Sliding, Absolute }
+
+/// Editable OAuth/OIDC scalar settings, independent of persistence entities.
+public sealed record ClientSettings
+{
+ [Required, MaxLength(200)] public string ClientId { get; init; } = "";
+ public bool Enabled { get; init; } = true;
+ public string? ClientName { get; init; }
+ public string? Description { get; init; }
+ public string? ClientUri { get; init; }
+ public string? LogoUri { get; init; }
+ [Required] public string ProtocolType { get; init; } = "oidc";
+ public bool RequireClientSecret { get; init; } = true;
+ public bool RequireConsent { get; init; }
+ public bool AllowRememberConsent { get; init; } = true;
+ public bool AlwaysIncludeUserClaimsInIdToken { get; init; }
+ public bool RequirePkce { get; init; } = true;
+ public bool AllowPlainTextPkce { get; init; }
+ public bool RequireRequestObject { get; init; }
+ public bool AllowAccessTokensViaBrowser { get; init; }
+ public string? FrontChannelLogoutUri { get; init; }
+ public bool FrontChannelLogoutSessionRequired { get; init; } = true;
+ public string? BackChannelLogoutUri { get; init; }
+ public bool BackChannelLogoutSessionRequired { get; init; } = true;
+ public bool AllowOfflineAccess { get; init; }
+ [Range(1, int.MaxValue)] public int IdentityTokenLifetime { get; init; } = 300;
+ [Range(1, int.MaxValue)] public int AccessTokenLifetime { get; init; } = 3600;
+ [Range(1, int.MaxValue)] public int AuthorizationCodeLifetime { get; init; } = 300;
+ [Range(1, int.MaxValue)] public int AbsoluteRefreshTokenLifetime { get; init; } = 2592000;
+ [Range(1, int.MaxValue)] public int SlidingRefreshTokenLifetime { get; init; } = 1296000;
+ [Range(1, int.MaxValue)] public int? ConsentLifetime { get; init; }
+ [EnumDataType(typeof(ClientAccessTokenType))] public ClientAccessTokenType AccessTokenType { get; init; }
+ [EnumDataType(typeof(ClientRefreshTokenExpiration))]
+ public ClientRefreshTokenExpiration RefreshTokenExpiration { get; init; } = ClientRefreshTokenExpiration.Absolute;
+ [EnumDataType(typeof(ClientRefreshTokenUsage))]
+ public ClientRefreshTokenUsage RefreshTokenUsage { get; init; } = ClientRefreshTokenUsage.OneTimeOnly;
+ public bool UpdateAccessTokenClaimsOnRefresh { get; init; }
+ public bool EnableLocalLogin { get; init; } = true;
+ public bool IncludeJwtId { get; init; } = true;
+ public bool AlwaysSendClientClaims { get; init; }
+ public string? ClientClaimsPrefix { get; init; } = "client_";
+ public string? PairWiseSubjectSalt { get; init; }
+ [Range(1, int.MaxValue)] public int? UserSsoLifetime { get; init; }
+ public string? UserCodeType { get; init; }
+ [Range(1, int.MaxValue)] public int DeviceCodeLifetime { get; init; } = 300;
+ public string? AllowedIdentityTokenSigningAlgorithms { get; init; }
+}
+
+/// A client configuration collection supported by the web administration.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ClientCollection
+{
+ RedirectUris, PostLogoutRedirectUris, Scopes, GrantTypes, CorsOrigins,
+ IdentityProviders, Claims, Properties
+}
+
+/// An editable collection entry; Key is used for claims and properties.
+public sealed record ClientEntry
+{
+ public string? Key { get; init; }
+ [Required, MaxLength(4096)] public string Value { get; init; } = "";
+}
+
+/// A persisted collection row, without any credential material.
+public sealed record ClientEntryResponse(int Id, ClientCollection Collection, string? Key, string Value);
+
+/// Secret metadata; neither plaintext nor a stored hash is exposed.
+public sealed record ClientSecretInfo(int Id, string? Description, DateTimeOffset Created, DateTimeOffset? Expiration);
+
+/// A client and all its administrable collections.
+public sealed record AdministrationClient(int Id, ClientSettings Settings,
+ IReadOnlyList Entries, IReadOnlyList Secrets);
+
+/// Input for adding a shared secret or generating a replacement.
+public sealed record ClientSecretRequest
+{
+ [MaxLength(4096)] public string? Value { get; init; }
+ [MaxLength(2000)] public string? Description { get; init; }
+ public DateTimeOffset? Expiration { get; init; }
+}
+
+/// A newly generated client secret returned once, never available through GET.
+public sealed record NewClientSecret(int Id, string Value, DateTimeOffset? Expiration);
+
+/// Editable API scope settings.
+public sealed record ApiScopeSettings
+{
+ [Required, MaxLength(200)] public string Name { get; init; } = "";
+ public string? DisplayName { get; init; }
+ public string? Description { get; init; }
+ public bool Enabled { get; init; } = true;
+ public bool Required { get; init; }
+ public bool Emphasize { get; init; }
+ public bool ShowInDiscoveryDocument { get; init; } = true;
+}
+
+/// A persisted API scope.
+public sealed record AdministrationScope(int Id, ApiScopeSettings Settings);
+
+/// Mail delivery periodicity, compatible with the server calendar model.
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum MailPeriodicity { OnDemand = -1, Daily, Weekly, Monthly, ThreeM, SixM, Yearly }
+
+/// Editable mail template settings.
+public sealed record MailingTemplateSettings
+{
+ [Required, StringLength(256, MinimumLength = 3)] public string Id { get; init; } = "";
+ [Required, StringLength(256, MinimumLength = 3)] public string Topic { get; init; } = "";
+ [Required, MaxLength(65536)] public string Body { get; init; } = "";
+ [Required, EmailAddress] public string ReplyToAddress { get; init; } = "";
+ [EnumDataType(typeof(MailPeriodicity))] public MailPeriodicity ToSend { get; init; } = MailPeriodicity.OnDemand;
+}
+
+/// A safe account business-data transfer without credentials, confirmations or roles.
+public sealed record UserBusinessData
+{
+ [Required] public string Id { get; init; } = "";
+ [Required, MaxLength(256)] public string UserName { get; init; } = "";
+ [Required, EmailAddress] public string Email { get; init; } = "";
+ [MaxLength(512)] public string? FullName { get; init; }
+ [MaxLength(512)] public string? Avatar { get; init; }
+ [MaxLength(512)] public string? DedicatedGoogleCalendar { get; init; }
+ public bool AllowMonthlyEmail { get; init; }
+ [Range(0, long.MaxValue)] public long DiskQuota { get; init; } = 536870912;
+ [Range(0, long.MaxValue)] public long MaxFileSize { get; init; } = 536870912;
+}
+
+/// A blog business-data transfer retaining publication and access-control references.
+public sealed record BlogBusinessData
+{
+ [Range(1, long.MaxValue)] public long Id { get; init; }
+ [Required, MaxLength(1024)] public string Title { get; init; } = "";
+ [MaxLength(56224)] public string? Article { get; init; }
+ [MaxLength(1024)] public string? Photo { get; init; }
+ [Required] public string AuthorId { get; init; } = "";
+ [JsonRequired] public bool Published { get; init; }
+ public DateTimeOffset DateCreated { get; init; }
+ [JsonRequired] public long[] CircleIds { get; init; } = [];
+ public string[] Tags { get; init; } = [];
+}
+
+/// An activity business-data transfer without navigation entities.
+public sealed record ActivityBusinessData
+{
+ [Required, MaxLength(512)] public string Code { get; init; } = "";
+ [Required, MaxLength(512)] public string Name { get; init; } = "";
+ [MaxLength(512)] public string? ParentCode { get; init; }
+ public string? Description { get; init; }
+ public string? Photo { get; init; }
+ public bool Moderated { get; init; }
+ public string? ModeratorGroupName { get; init; }
+ [Range(0, 100)] public int Rate { get; init; }
+ public string? SettingsClassName { get; init; }
+ public bool Hidden { get; init; }
+}
+
+/// A versioned business-data transfer. No authentication secrets are part of this schema.
+public sealed record AdministrationTransfer
+{
+ [JsonRequired, Range(1, 1)] public int Version { get; init; } = 1;
+ [JsonRequired, Required] public UserBusinessData[] Users { get; init; } = [];
+ [JsonRequired, Required] public BlogBusinessData[] Blogs { get; init; } = [];
+ [JsonRequired, Required] public ActivityBusinessData[] Activities { get; init; } = [];
+}
+
+/// Counts of rows successfully imported in a single transaction.
+public sealed record ImportResult(int Users, int Blogs, int Activities);
diff --git a/src/Yavsc.Api.Client/AdministrationApiClient.cs b/src/Yavsc.Api.Client/AdministrationApiClient.cs
new file mode 100644
index 000000000..bc7cd2287
--- /dev/null
+++ b/src/Yavsc.Api.Client/AdministrationApiClient.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Abstract.Administration;
+
+namespace Yavsc.Api.Client;
+
+public sealed class AdministrationApiClient(IYavscApiClient api, Func baseAddress)
+{
+ private string Path(string relative) => new Uri(new Uri(baseAddress().TrimEnd('/') + "/", UriKind.Absolute),
+ "administration/" + relative).ToString();
+ private static string Escape(string value) => Uri.EscapeDataString(value);
+
+ public Task GetSummaryAsync(CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Get, Path("summary"), ct: ct);
+ public Task> GetRolesAsync(CancellationToken ct = default)
+ => api.CallAsync>(HttpMethod.Get, Path("roles"), ct: ct);
+ public Task EnsureRolesAsync(CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Post, Path("roles/ensure"), ct: ct);
+ public Task> GetRoleMembersAsync(string roleId, CancellationToken ct = default)
+ => api.CallAsync>(HttpMethod.Get, Path($"roles/{Escape(roleId)}/members"), ct: ct);
+ public Task SetRoleMembershipAsync(string roleId, string userId, bool member, CancellationToken ct = default)
+ => api.CallAsync(member ? HttpMethod.Put : HttpMethod.Delete,
+ Path($"roles/{Escape(roleId)}/members/{Escape(userId)}"), ct: ct);
+ public Task> GetClientsAsync(CancellationToken ct = default)
+ => api.CallAsync>(HttpMethod.Get, Path("clients"), ct: ct);
+ public Task SaveClientAsync(int? id, ClientSettings settings, CancellationToken ct = default)
+ => api.CallAsync(id.HasValue ? HttpMethod.Put : HttpMethod.Post,
+ Path(id.HasValue ? $"clients/{id}" : "clients"), settings, ct);
+ public Task DeleteClientAsync(int id, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Delete, Path($"clients/{id}"), ct: ct);
+ public Task AddClientEntryAsync(int id, ClientCollection collection, ClientEntry entry, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Post, Path($"clients/{id}/collections/{collection}"), entry, ct);
+ public Task DeleteClientEntryAsync(int id, ClientCollection collection, int rowId, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Delete, Path($"clients/{id}/collections/{collection}/{rowId}"), ct: ct);
+ public Task CreateSecretAsync(int id, ClientSecretRequest request, bool replace, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Post, Path($"clients/{id}/secrets{(replace ? "/regenerate" : "")}"), request, ct);
+ public Task DeleteSecretAsync(int id, int secretId, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Delete, Path($"clients/{id}/secrets/{secretId}"), ct: ct);
+ public Task> GetScopesAsync(CancellationToken ct = default)
+ => api.CallAsync>(HttpMethod.Get, Path("scopes"), ct: ct);
+ public Task SaveScopeAsync(int? id, ApiScopeSettings settings, CancellationToken ct = default)
+ => api.CallAsync(id.HasValue ? HttpMethod.Put : HttpMethod.Post,
+ Path(id.HasValue ? $"scopes/{id}" : "scopes"), settings, ct);
+ public Task DeleteScopeAsync(int id, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Delete, Path($"scopes/{id}"), ct: ct);
+ public Task> GetMailingTemplatesAsync(CancellationToken ct = default)
+ => api.CallAsync>(HttpMethod.Get, Path("mailing-templates"), ct: ct);
+ public Task> GetMailingCriteriaAsync(CancellationToken ct = default)
+ => api.CallAsync>(HttpMethod.Get, Path("mailing-criteria"), ct: ct);
+ public Task SaveMailingTemplateAsync(string? id, MailingTemplateSettings settings, CancellationToken ct = default)
+ => api.CallAsync(id is not null ? HttpMethod.Put : HttpMethod.Post,
+ Path(id is not null ? $"mailing-templates/{Escape(id)}" : "mailing-templates"), settings, ct);
+ public Task DeleteMailingTemplateAsync(string id, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Delete, Path($"mailing-templates/{Escape(id)}"), ct: ct);
+ public Task ExportAsync(CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Get, Path("transfer"), ct: ct);
+ public Task ImportAsync(AdministrationTransfer transfer, CancellationToken ct = default)
+ => api.CallAsync(HttpMethod.Post, Path("transfer"), transfer, ct);
+}
diff --git a/src/Yavsc.Api/Administration.http b/src/Yavsc.Api/Administration.http
new file mode 100644
index 000000000..c1de4ae7d
--- /dev/null
+++ b/src/Yavsc.Api/Administration.http
@@ -0,0 +1,169 @@
+@baseUrl = https://localhost:5104
+@token = REPLACE_WITH_ADMINISTRATOR_ACCESS_TOKEN
+@root = {{baseUrl}}/api/v1/administration
+@roleId = REPLACE_WITH_ROLE_ID
+@userId = REPLACE_WITH_USER_ID
+@clientId = 1
+@rowId = 1
+@scopeId = 1
+@templateId = REPLACE_WITH_MAILING_CRITERION
+
+### Protected OpenAPI document (development only)
+GET {{baseUrl}}/openapi/administration.json
+Authorization: Bearer {{token}}
+
+### Summary
+GET {{root}}/summary
+Authorization: Bearer {{token}}
+
+### Roles
+GET {{root}}/roles
+Authorization: Bearer {{token}}
+
+### Initialize standard roles
+POST {{root}}/roles/ensure
+Authorization: Bearer {{token}}
+
+### Members
+GET {{root}}/roles/{{roleId}}/members
+Authorization: Bearer {{token}}
+
+### Enroll
+PUT {{root}}/roles/{{roleId}}/members/{{userId}}
+Authorization: Bearer {{token}}
+
+### Remove membership (409 for last administrator)
+DELETE {{root}}/roles/{{roleId}}/members/{{userId}}
+Authorization: Bearer {{token}}
+
+### Clients
+GET {{root}}/clients
+Authorization: Bearer {{token}}
+
+### Client detail (metadata only for secrets)
+GET {{root}}/clients/{{clientId}}
+Authorization: Bearer {{token}}
+
+### Create client
+POST {{root}}/clients
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"clientId":"example-postit","clientName":"Example PostIt","requireClientSecret":false,"requirePkce":true}
+
+### Update client: send all scalar settings from GET, retaining unedited values
+PUT {{root}}/clients/{{clientId}}
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"clientId":"example-postit","clientName":"Example PostIt","requireClientSecret":false,"requirePkce":true}
+
+### Add a collection row
+# Collections: RedirectUris, PostLogoutRedirectUris, Scopes, GrantTypes,
+# CorsOrigins, IdentityProviders, Claims, Properties.
+# Claims and Properties also require a "key".
+POST {{root}}/clients/{{clientId}}/collections/RedirectUris
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"value":"postit://callback"}
+
+### Remove collection row
+DELETE {{root}}/clients/{{clientId}}/collections/RedirectUris/{{rowId}}
+Authorization: Bearer {{token}}
+
+### Generate an additional secret (plaintext returned once; stored as SHA-256)
+POST {{root}}/clients/{{clientId}}/secrets
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"description":"Example generated secret"}
+
+### Generate a replacement, removing all previous secrets
+POST {{root}}/clients/{{clientId}}/secrets/regenerate
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"description":"Example rotation"}
+
+### Remove a secret
+DELETE {{root}}/clients/{{clientId}}/secrets/{{rowId}}
+Authorization: Bearer {{token}}
+
+### Delete client
+DELETE {{root}}/clients/{{clientId}}
+Authorization: Bearer {{token}}
+
+### Scopes
+GET {{root}}/scopes
+Authorization: Bearer {{token}}
+
+### Scope detail
+GET {{root}}/scopes/{{scopeId}}
+Authorization: Bearer {{token}}
+
+### Create scope
+POST {{root}}/scopes
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"name":"example-api","displayName":"Example API","enabled":true}
+
+### Update scope
+PUT {{root}}/scopes/{{scopeId}}
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"name":"example-api","displayName":"Example API","description":"Updated description","enabled":true}
+
+### Delete scope
+DELETE {{root}}/scopes/{{scopeId}}
+Authorization: Bearer {{token}}
+
+### Mailing criteria
+GET {{root}}/mailing-criteria
+Authorization: Bearer {{token}}
+
+### Mailing templates
+GET {{root}}/mailing-templates
+Authorization: Bearer {{token}}
+
+### Template detail
+GET {{root}}/mailing-templates/{{templateId}}
+Authorization: Bearer {{token}}
+
+### Create template (use an ID returned by mailing-criteria)
+POST {{root}}/mailing-templates
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"id":"{{templateId}}","topic":"Example topic","body":"Example body","replyToAddress":"reply@example.invalid","toSend":"OnDemand"}
+
+### Update template
+PUT {{root}}/mailing-templates/{{templateId}}
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"id":"{{templateId}}","topic":"Updated topic","body":"Updated body","replyToAddress":"reply@example.invalid","toSend":"Monthly"}
+
+### Delete template
+DELETE {{root}}/mailing-templates/{{templateId}}
+Authorization: Bearer {{token}}
+
+### Export business data, never account authentication material
+GET {{root}}/transfer
+Authorization: Bearer {{token}}
+
+### Atomic import/upsert (16 MiB maximum; an empty transfer changes nothing)
+POST {{root}}/transfer
+Authorization: Bearer {{token}}
+Content-Type: application/json
+
+{"version":1,"users":[],"blogs":[],"activities":[]}
+
+### Missing resource
+GET {{root}}/clients/2147483647
+Authorization: Bearer {{token}}
+
+### Anonymous access must be rejected (401)
+GET {{root}}/summary
diff --git a/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs b/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs
index b13112557..0f6af3083 100644
--- a/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs
+++ b/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs
@@ -78,7 +78,7 @@ public sealed class ResourceUsageApiController : Controller
[HttpGet(Constants.ResourceUsageAdminRoute)]
public async Task>> GetAdminOverview()
{
- if (!User.IsInRole(Constants.AdminGroupName) && !User.IsInRole(Constants.BlogModeratorGroupName))
+ if (!User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
diff --git a/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs b/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs
new file mode 100644
index 000000000..cd4352fbe
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs
@@ -0,0 +1,215 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Yavsc.Abstract.Administration;
+using Yavsc.Api.Services;
+
+namespace Yavsc.ApiControllers.Administration;
+
+[ApiController]
+[ApiExplorerSettings(GroupName = "administration")]
+[Authorize("AdministratorOnly")]
+[Route(Constants.APIPrefix + "/administration")]
+[Produces("application/json")]
+[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
+[ProducesResponseType(400)]
+[ProducesResponseType(404)]
+[ProducesResponseType(409)]
+[ProducesResponseType(401)]
+[ProducesResponseType(403)]
+[ServiceFilter(typeof(AdministrationExceptionFilter))]
+public sealed class SiteAdministrationApiController(IAdministrationService service) : ControllerBase
+{
+ [HttpGet("summary"), EndpointSummary("Statistiques du site et informations du logiciel")]
+ [ProducesResponseType(200)]
+ public async Task> Summary(CancellationToken ct)
+ => Ok(await service.GetSummaryAsync(ct));
+
+ [HttpGet("roles"), EndpointSummary("Lister les rôles et leurs effectifs")]
+ public async Task>> Roles(CancellationToken ct)
+ => Ok(await service.GetRolesAsync(ct));
+
+ [HttpPost("roles/ensure"), EndpointSummary("Initialiser les rôles standards"), ProducesResponseType(204)]
+ public async Task EnsureRoles(CancellationToken ct)
+ {
+ await service.EnsureRolesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpGet("roles/{id}/members"), EndpointSummary("Lister les membres d’un rôle")]
+ public async Task>> RoleMembers(string id, CancellationToken ct)
+ => Ok(await service.GetRoleMembersAsync(id, ct));
+
+ [HttpPut("roles/{id}/members/{userId}"), EndpointSummary("Inscrire un utilisateur dans un rôle"), ProducesResponseType(204)]
+ public async Task Enroll(string id, string userId, CancellationToken ct)
+ {
+ await service.SetRoleMembershipAsync(id, userId, true, ct);
+ return NoContent();
+ }
+
+ [HttpDelete("roles/{id}/members/{userId}"), EndpointSummary("Retirer un membre, sauf le dernier administrateur"), ProducesResponseType(204)]
+ public async Task Fire(string id, string userId, CancellationToken ct)
+ {
+ await service.SetRoleMembershipAsync(id, userId, false, ct);
+ return NoContent();
+ }
+
+ [HttpGet("clients"), EndpointSummary("Lister les clients OAuth/OIDC sans exposer leurs secrets")]
+ public async Task>> Clients(CancellationToken ct)
+ => Ok(await service.GetClientsAsync(ct));
+
+ [HttpGet("clients/{id:int}"), EndpointSummary("Consulter la configuration d’un client")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ public async Task> Client(int id, CancellationToken ct)
+ {
+ var client = await service.GetClientAsync(id, ct);
+ return client is null ? NotFound() : Ok(client);
+ }
+
+ [HttpPost("clients"), EndpointSummary("Créer un client avec les valeurs par défaut PKCE")]
+ [ProducesResponseType(201)]
+ public async Task> CreateClient(ClientSettings settings, CancellationToken ct)
+ {
+ var client = await service.SaveClientAsync(null, settings, ct);
+ return CreatedAtAction(nameof(Client), new { id = client.Id }, client);
+ }
+
+ [HttpPut("clients/{id:int}"), EndpointSummary("Remplacer les paramètres scalaires d’un client")]
+ public async Task> UpdateClient(int id, ClientSettings settings, CancellationToken ct)
+ => Ok(await service.SaveClientAsync(id, settings, ct));
+
+ [HttpDelete("clients/{id:int}"), EndpointSummary("Supprimer un client et ses collections"), ProducesResponseType(204)]
+ public async Task DeleteClient(int id, CancellationToken ct)
+ {
+ await service.DeleteClientAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpPost("clients/{id:int}/collections/{collection}"), EndpointSummary("Ajouter une entrée à une collection du client"), ProducesResponseType(204)]
+ public async Task AddEntry(int id, ClientCollection collection, ClientEntry entry, CancellationToken ct)
+ {
+ await service.AddClientEntryAsync(id, collection, entry, ct);
+ return NoContent();
+ }
+
+ [HttpDelete("clients/{id:int}/collections/{collection}/{rowId:int}"), EndpointSummary("Supprimer une entrée du client"), ProducesResponseType(204)]
+ public async Task DeleteEntry(int id, ClientCollection collection, int rowId, CancellationToken ct)
+ {
+ await service.DeleteClientEntryAsync(id, collection, rowId, ct);
+ return NoContent();
+ }
+
+ [HttpPost("clients/{id:int}/secrets"), EndpointSummary("Ajouter un secret et le restituer une seule fois")]
+ [ProducesResponseType(201)]
+ [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
+ public async Task> AddSecret(int id, ClientSecretRequest request, CancellationToken ct)
+ {
+ var secret = await service.CreateSecretAsync(id, request, false, ct);
+ return CreatedAtAction(nameof(Client), new { id }, secret);
+ }
+
+ [HttpPost("clients/{id:int}/secrets/regenerate"), EndpointSummary("Générer un secret remplaçant tous les précédents")]
+ [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
+ public async Task> RegenerateSecret(int id, ClientSecretRequest request, CancellationToken ct)
+ => Ok(await service.CreateSecretAsync(id, request, true, ct));
+
+ [HttpDelete("clients/{id:int}/secrets/{secretId:int}"), EndpointSummary("Supprimer un secret du client"), ProducesResponseType(204)]
+ public async Task DeleteSecret(int id, int secretId, CancellationToken ct)
+ {
+ await service.DeleteSecretAsync(id, secretId, ct);
+ return NoContent();
+ }
+
+ [HttpGet("scopes"), EndpointSummary("Lister les scopes API")]
+ public async Task>> Scopes(CancellationToken ct)
+ => Ok(await service.GetScopesAsync(ct));
+
+ [HttpGet("scopes/{id:int}"), EndpointSummary("Consulter un scope API")]
+ public async Task> Scope(int id, CancellationToken ct)
+ {
+ var scope = (await service.GetScopesAsync(ct)).SingleOrDefault(scope => scope.Id == id);
+ return scope is null ? NotFound() : Ok(scope);
+ }
+
+ [HttpPost("scopes"), EndpointSummary("Créer un scope API")]
+ [ProducesResponseType(201)]
+ public async Task> CreateScope(ApiScopeSettings settings, CancellationToken ct)
+ {
+ var scope = await service.SaveScopeAsync(null, settings, ct);
+ return CreatedAtAction(nameof(Scope), new { id = scope.Id }, scope);
+ }
+
+ [HttpPut("scopes/{id:int}"), EndpointSummary("Modifier un scope API")]
+ public async Task> UpdateScope(int id, ApiScopeSettings settings, CancellationToken ct)
+ => Ok(await service.SaveScopeAsync(id, settings, ct));
+
+ [HttpDelete("scopes/{id:int}"), EndpointSummary("Supprimer un scope API"), ProducesResponseType(204)]
+ public async Task DeleteScope(int id, CancellationToken ct)
+ {
+ await service.DeleteScopeAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("mailing-criteria"), EndpointSummary("Lister les critères de diffusion utilisables dans les modèles")]
+ public ActionResult> MailingCriteria(CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ return Ok(service.GetMailingCriteria());
+ }
+
+ [HttpGet("mailing-templates"), EndpointSummary("Lister les modèles de courriels")]
+ public async Task>> MailingTemplates(CancellationToken ct)
+ => Ok(await service.GetMailingTemplatesAsync(ct));
+
+ [HttpGet("mailing-templates/{id}"), EndpointSummary("Consulter un modèle de courriel")]
+ public async Task> MailingTemplate(string id, CancellationToken ct)
+ {
+ var template = (await service.GetMailingTemplatesAsync(ct)).SingleOrDefault(template => template.Id == id);
+ return template is null ? NotFound() : Ok(template);
+ }
+
+ [HttpPost("mailing-templates"), EndpointSummary("Créer un modèle de courriel")]
+ [ProducesResponseType(201)]
+ public async Task> CreateMailingTemplate(MailingTemplateSettings settings, CancellationToken ct)
+ {
+ var template = await service.SaveMailingTemplateAsync(null, settings, ct);
+ return CreatedAtAction(nameof(MailingTemplate), new { id = template.Id }, template);
+ }
+
+ [HttpPut("mailing-templates/{id}"), EndpointSummary("Modifier un modèle de courriel")]
+ public async Task> UpdateMailingTemplate(string id, MailingTemplateSettings settings, CancellationToken ct)
+ => Ok(await service.SaveMailingTemplateAsync(id, settings, ct));
+
+ [HttpDelete("mailing-templates/{id}"), EndpointSummary("Supprimer un modèle de courriel"), ProducesResponseType(204)]
+ public async Task DeleteMailingTemplate(string id, CancellationToken ct)
+ {
+ await service.DeleteMailingTemplateAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("transfer"), EndpointSummary("Exporter les données métier sans matériel d’authentification")]
+ [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
+ public async Task> Export(CancellationToken ct)
+ => Ok(await service.ExportAsync(ct));
+
+ [HttpPost("transfer"), EndpointSummary("Importer les données métier en une transaction, limite 16 Mio")]
+ [RequestSizeLimit(16 * 1024 * 1024)]
+ public async Task> Import(AdministrationTransfer transfer, CancellationToken ct)
+ => Ok(await service.ImportAsync(transfer, ct));
+}
+
+public sealed class AdministrationExceptionFilter(ILogger logger) : IExceptionFilter
+{
+ public void OnException(ExceptionContext context)
+ {
+ if (context.Exception is not AdministrationOperationException exception)
+ return;
+ logger.LogWarning("Administration request rejected with status {Status}: {Reason}", exception.StatusCode, exception.Message);
+ context.Result = new ObjectResult(new ProblemDetails
+ {
+ Status = exception.StatusCode, Title = exception.Message
+ }) { StatusCode = exception.StatusCode };
+ context.ExceptionHandled = true;
+ }
+}
diff --git a/src/Yavsc.Api/Controllers/accounting/ApplicationUserApiController.cs b/src/Yavsc.Api/Controllers/accounting/ApplicationUserApiController.cs
index 326760b7c..05d038585 100644
--- a/src/Yavsc.Api/Controllers/accounting/ApplicationUserApiController.cs
+++ b/src/Yavsc.Api/Controllers/accounting/ApplicationUserApiController.cs
@@ -7,7 +7,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
- [Produces("application/json"), Authorize]
+ [Produces("application/json"), Authorize("AdministratorOnly")]
[Route(Constants.APIPrefix + "/users")]
public class ApplicationUserApiController : Controller
{
@@ -19,7 +19,7 @@ namespace Yavsc.Controllers
}
// GET: api/ApplicationUserApi
- [HttpGet, Authorize(Roles = Constants.AdminGroupName + "," + Constants.BlogModeratorGroupName)]
+ [HttpGet]
public IEnumerable GetApplicationUser(int skip=0, int take = 25)
{
return _context.Users
@@ -41,7 +41,7 @@ namespace Yavsc.Controllers
});
}
- [HttpGet("search/{pattern}"), Authorize(Roles = Constants.AdminGroupName + "," + Constants.BlogModeratorGroupName)]
+ [HttpGet("search/{pattern}")]
public IEnumerable SearchApplicationUser(string pattern, int skip=0, int take = 25)
{
var normalizedPattern = pattern ?? string.Empty;
diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs
index be9601d49..db96ebce7 100644
--- a/src/Yavsc.Api/Program.cs
+++ b/src/Yavsc.Api/Program.cs
@@ -13,6 +13,7 @@ using Yavsc;
using Yavsc.Server.Helpers;
using Yavsc.Services;
using Yavsc.Server.Hubs;
+using Yavsc.Api.Services;
internal class Program
{
@@ -25,6 +26,7 @@ internal class Program
builder.AddConfiguration("api");
var services = builder.Services;
+ services.AddSiteAdministration();
// Anthropic client
builder.Services.AddSingleton(_ =>
@@ -132,7 +134,10 @@ internal class Program
using (var app = builder.Build())
{
if (app.Environment.IsDevelopment())
+ {
app.UseDeveloperExceptionPage();
+ app.MapOpenApi().RequireAuthorization("AdministratorOnly");
+ }
app
.UseRouting()
diff --git a/src/Yavsc.Api/Services/AdministrationService.Transfers.cs b/src/Yavsc.Api/Services/AdministrationService.Transfers.cs
new file mode 100644
index 000000000..1c6b94e78
--- /dev/null
+++ b/src/Yavsc.Api/Services/AdministrationService.Transfers.cs
@@ -0,0 +1,196 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Abstract.Administration;
+using Yavsc.Models;
+using Yavsc.Models.Access;
+using Yavsc.Models.Blog;
+using Yavsc.Models.Relationship;
+using Yavsc.Models.Workflow;
+
+namespace Yavsc.Api.Services;
+
+public sealed partial class AdministrationService
+{
+ public async Task ExportAsync(CancellationToken ct)
+ {
+ var userData = await db.Users.AsNoTracking().OrderBy(user => user.Id)
+ .Select(user => new UserBusinessData
+ {
+ Id = user.Id, UserName = user.UserName!, Email = user.Email!, FullName = user.FullName,
+ Avatar = user.Avatar, DedicatedGoogleCalendar = user.DedicatedGoogleCalendar,
+ AllowMonthlyEmail = user.AllowMonthlyEmail, DiskQuota = user.DiskQuota, MaxFileSize = user.MaxFileSize
+ }).ToArrayAsync(ct);
+ var blogs = await db.BlogSpot.AsNoTracking().Include(post => post.ACL).Include(post => post.Publication)
+ .Include(post => post.Tags).ThenInclude(tag => tag.Tag).OrderBy(post => post.Id).AsSplitQuery().ToListAsync(ct);
+ var activities = await db.Activities.AsNoTracking().OrderBy(activity => activity.Code)
+ .Select(activity => new ActivityBusinessData
+ {
+ Code = activity.Code, Name = activity.Name, ParentCode = activity.ParentCode,
+ Description = activity.Description, Photo = activity.Photo, Moderated = activity.Moderated,
+ ModeratorGroupName = activity.ModeratorGroupName, Rate = activity.Rate,
+ SettingsClassName = activity.SettingsClassName, Hidden = activity.Hidden
+ }).ToArrayAsync(ct);
+ return new AdministrationTransfer
+ {
+ Users = userData, Activities = activities,
+ Blogs = blogs.Select(post => new BlogBusinessData
+ {
+ Id = post.Id, AuthorId = post.AuthorId!, Title = post.Title, Article = post.Article, Photo = post.Photo,
+ DateCreated = Utc(post.DateCreated), Published = post.Publication is not null,
+ CircleIds = post.ACL?.Select(acl => acl.CircleId).ToArray() ?? [],
+ Tags = post.Tags.Select(tag => tag.Tag.Name).ToArray()
+ }).ToArray()
+ };
+ }
+
+ public async Task ImportAsync(AdministrationTransfer transfer, CancellationToken ct)
+ {
+ Validate(transfer);
+ foreach (var item in transfer.Users) Validate(item);
+ foreach (var item in transfer.Blogs)
+ {
+ Validate(item);
+ if (item.CircleIds is null || item.Tags is null || item.Tags.Any(string.IsNullOrWhiteSpace))
+ throw Invalid("Les cercles et étiquettes d’un billet doivent être valides.");
+ }
+ foreach (var item in transfer.Activities) Validate(item);
+ Unique(transfer.Users.Select(user => user.Id));
+ Unique(transfer.Users.Select(user => user.UserName.ToUpperInvariant()));
+ Unique(transfer.Blogs.Select(post => post.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)));
+ Unique(transfer.Activities.Select(activity => activity.Code));
+
+ await using var transaction = await db.Database.BeginTransactionAsync(ct);
+ if (transfer.Blogs.Length > 0 && db.Database.IsNpgsql())
+ await db.Database.ExecuteSqlRawAsync("""LOCK TABLE "BlogSpot" IN SHARE ROW EXCLUSIVE MODE""", ct);
+ foreach (var item in transfer.Users)
+ {
+ ct.ThrowIfCancellationRequested();
+ var user = await users.FindByIdAsync(item.Id);
+ var isNew = user is null;
+ if (user is not null && user.Email != item.Email)
+ throw Conflict("L’adresse e-mail d’un compte existant est une clé métier ; elle ne peut pas changer par import.");
+ user ??= new ApplicationUser { Id = item.Id };
+ if (user.Email != item.Email)
+ user.EmailConfirmed = false;
+ user.UserName = item.UserName;
+ user.Email = item.Email;
+ user.FullName = item.FullName;
+ user.Avatar = item.Avatar;
+ user.DedicatedGoogleCalendar = item.DedicatedGoogleCalendar;
+ user.AllowMonthlyEmail = item.AllowMonthlyEmail;
+ user.DiskQuota = item.DiskQuota;
+ user.MaxFileSize = item.MaxFileSize;
+ CheckIdentity(isNew ? await users.CreateAsync(user) : await users.UpdateAsync(user));
+ }
+
+ var activities = await db.Activities.ToDictionaryAsync(activity => activity.Code, ct);
+ foreach (var item in transfer.Activities)
+ {
+ if (!activities.TryGetValue(item.Code, out var activity))
+ {
+ activity = new Activity { Code = item.Code };
+ activities.Add(item.Code, activity);
+ db.Activities.Add(activity);
+ }
+ activity.Name = item.Name;
+ activity.ParentCode = item.ParentCode;
+ activity.Description = item.Description;
+ activity.Photo = item.Photo;
+ activity.Moderated = item.Moderated;
+ activity.ModeratorGroupName = item.ModeratorGroupName;
+ activity.Rate = item.Rate;
+ activity.SettingsClassName = item.SettingsClassName;
+ activity.Hidden = item.Hidden;
+ }
+ foreach (var item in transfer.Activities)
+ {
+ var visited = new HashSet { item.Code };
+ var parent = item.ParentCode;
+ while (parent is not null)
+ {
+ if (!visited.Add(parent)) throw Invalid("Le graphe des activités contient un cycle.");
+ if (!activities.TryGetValue(parent, out var ancestor)) throw Invalid($"Activité parent inconnue : {parent}.");
+ parent = ancestor.ParentCode;
+ }
+ }
+ await db.SaveChangesAsync(Actor, ct);
+
+ var existingTags = await db.Tags.OrderBy(tag => tag.Id).ToListAsync(ct);
+ var tags = existingTags.GroupBy(tag => tag.Name).ToDictionary(group => group.Key, group => group.First());
+ foreach (var item in transfer.Blogs)
+ {
+ if (!await db.Users.AnyAsync(user => user.Id == item.AuthorId, ct))
+ throw Invalid($"Auteur inconnu : {item.AuthorId}.");
+ foreach (var circleId in item.CircleIds.Distinct())
+ {
+ if (!await db.Circle.AnyAsync(circle => circle.Id == circleId, ct))
+ throw Invalid($"Cercle inconnu : {circleId}. Les droits du billet ne peuvent pas être élargis.");
+ }
+ var post = await db.BlogSpot.Include(post => post.ACL).Include(post => post.Tags)
+ .Include(post => post.Publication).SingleOrDefaultAsync(post => post.Id == item.Id, ct);
+ if (post is null)
+ {
+ post = new BlogPost { Id = item.Id, ACL = [], Tags = [] };
+ db.BlogSpot.Add(post);
+ }
+ else if (post.AuthorId != item.AuthorId)
+ throw Conflict("Un import ne peut pas changer l’auteur d’un billet existant.");
+ post.AuthorId = item.AuthorId;
+ post.Title = item.Title;
+ post.Article = item.Article;
+ post.Photo = item.Photo;
+ post.DateCreated = item.DateCreated.UtcDateTime;
+ post.DateModified = DateTime.UtcNow;
+ post.UserModified = Actor;
+ post.UserCreated ??= item.AuthorId;
+ post.ACL ??= [];
+ post.Tags ??= [];
+ db.RemoveRange(post.ACL);
+ post.ACL = item.CircleIds.Distinct().Select(id => new CircleAuthorizationToBlogPost { CircleId = id }).ToList();
+ db.RemoveRange(post.Tags);
+ post.Tags = item.Tags.Distinct().Select(name =>
+ {
+ if (!tags.TryGetValue(name, out var tag))
+ {
+ tag = new Tag { Name = name };
+ tags.Add(name, tag);
+ }
+ return new BlogTag { Tag = tag };
+ }).ToList();
+ if (item.Published && post.Publication is null)
+ post.Publication = new BlogSpotPublication();
+ else if (!item.Published && post.Publication is not null)
+ {
+ db.Remove(post.Publication);
+ post.Publication = null!;
+ }
+ }
+ await db.SaveChangesAsync(ct);
+ if (transfer.Blogs.Length > 0 && db.Database.IsNpgsql())
+ {
+ // Explicit imported IDs must not collide with subsequently generated IDs.
+ await db.Database.ExecuteSqlRawAsync("""
+ SELECT setval(pg_get_serial_sequence('"BlogSpot"', 'Id'),
+ GREATEST(COALESCE((SELECT MAX("Id") FROM "BlogSpot"), 1),
+ COALESCE(pg_sequence_last_value(pg_get_serial_sequence('"BlogSpot"', 'Id')::regclass), 1)), true)
+ """, ct);
+ }
+ await transaction.CommitAsync(ct);
+ return new(transfer.Users.Length, transfer.Blogs.Length, transfer.Activities.Length);
+ }
+
+ private static void Validate(object? value)
+ {
+ if (value is null) throw Invalid("Une ligne de transfert ne peut pas être nulle.");
+ var errors = new List();
+ if (!Validator.TryValidateObject(value, new ValidationContext(value), errors, true))
+ throw Invalid(string.Join("; ", errors.Select(error => error.ErrorMessage)));
+ }
+
+ private static void Unique(IEnumerable keys)
+ {
+ var values = keys.ToArray();
+ if (values.Distinct(StringComparer.Ordinal).Count() != values.Length)
+ throw Invalid("Le transfert contient des identifiants en double.");
+ }
+}
diff --git a/src/Yavsc.Api/Services/AdministrationService.cs b/src/Yavsc.Api/Services/AdministrationService.cs
new file mode 100644
index 000000000..65e8e4a72
--- /dev/null
+++ b/src/Yavsc.Api/Services/AdministrationService.cs
@@ -0,0 +1,336 @@
+using System.ComponentModel.DataAnnotations;
+using System.Security.Cryptography;
+using System.Text;
+using IdentityServer8.EntityFramework.Entities;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Abstract.Administration;
+using Yavsc.Models;
+using Yavsc.Server.Helpers;
+using Yavsc.Server.Models.Calendar;
+using Yavsc.Server.Models.EMailing;
+using Yavsc.Server.Settings;
+
+namespace Yavsc.Api.Services;
+
+public sealed partial class AdministrationService(
+ ApplicationDbContext db, UserManager users, RoleManager roles,
+ IHttpContextAccessor httpContext) : IAdministrationService
+{
+ private string Actor => httpContext.HttpContext?.User.GetUserId()
+ ?? throw new AdministrationOperationException(401, "Session administrateur indisponible.");
+
+ private static AdministrationOperationException Missing() => new(404, "Ressource introuvable.");
+ private static AdministrationOperationException Invalid(string message) => new(400, message);
+ private static AdministrationOperationException Conflict(string message) => new(409, message);
+
+ private static void CheckIdentity(IdentityResult result)
+ {
+ if (!result.Succeeded)
+ throw Invalid(string.Join("; ", result.Errors.Select(error => error.Description)));
+ }
+
+ public async Task GetSummaryAsync(CancellationToken ct)
+ => new(await db.Users.CountAsync(ct),
+ (await users.GetUsersInRoleAsync(Constants.AdminGroupName)).Count,
+ typeof(AdministrationService).Assembly.GetName().ToString(), System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
+
+ public async Task> GetRolesAsync(CancellationToken ct)
+ {
+ var all = await roles.Roles.OrderBy(role => role.Name).ToListAsync(ct);
+ var result = new List();
+ foreach (var role in all)
+ result.Add(new(role.Id, role.Name!, await db.UserRoles.CountAsync(row => row.RoleId == role.Id, ct)));
+ return result;
+ }
+
+ public async Task EnsureRolesAsync(CancellationToken ct)
+ {
+ foreach (var name in new[] { Constants.AdminGroupName, Constants.StarGroupName, Constants.PerformerGroupName,
+ Constants.FrontOfficeGroupName, Constants.StarHunterGroupName, Constants.BlogModeratorGroupName })
+ {
+ ct.ThrowIfCancellationRequested();
+ if (!await roles.RoleExistsAsync(name))
+ CheckIdentity(await roles.CreateAsync(new IdentityRole(name)));
+ }
+ }
+
+ public async Task> GetRoleMembersAsync(string roleId, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var role = await roles.FindByIdAsync(roleId) ?? throw Missing();
+ return (await users.GetUsersInRoleAsync(role.Name!))
+ .Select(user => new RoleMember(user.Id, user.UserName!, user.Email)).ToArray();
+ }
+
+ public async Task SetRoleMembershipAsync(string roleId, string userId, bool member, CancellationToken ct)
+ {
+ await using var transaction = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
+ var role = await roles.FindByIdAsync(roleId) ?? throw Missing();
+ var user = await users.FindByIdAsync(userId) ?? throw Missing();
+ var alreadyMember = await users.IsInRoleAsync(user, role.Name!);
+ if (member && !alreadyMember)
+ CheckIdentity(await users.AddToRoleAsync(user, role.Name!));
+ else if (!member && alreadyMember)
+ {
+ if (role.Name == Constants.AdminGroupName &&
+ (await users.GetUsersInRoleAsync(role.Name)).Count <= 1)
+ throw Conflict("Impossible de retirer le dernier administrateur.");
+ CheckIdentity(await users.RemoveFromRoleAsync(user, role.Name!));
+ }
+ await transaction.CommitAsync(ct);
+ }
+
+ private IQueryable ClientQuery => db.Clients
+ .Include(client => client.RedirectUris).Include(client => client.PostLogoutRedirectUris)
+ .Include(client => client.AllowedScopes).Include(client => client.AllowedGrantTypes)
+ .Include(client => client.AllowedCorsOrigins).Include(client => client.IdentityProviderRestrictions)
+ .Include(client => client.Claims).Include(client => client.Properties).Include(client => client.ClientSecrets)
+ .AsSplitQuery();
+
+ public async Task> GetClientsAsync(CancellationToken ct)
+ => (await ClientQuery.AsNoTracking().OrderBy(client => client.ClientId).ToListAsync(ct)).Select(ToClient).ToArray();
+
+ public async Task GetClientAsync(int id, CancellationToken ct)
+ {
+ var client = await ClientQuery.AsNoTracking().SingleOrDefaultAsync(client => client.Id == id, ct);
+ return client is null ? null : ToClient(client);
+ }
+
+ public async Task SaveClientAsync(int? id, ClientSettings settings, CancellationToken ct)
+ {
+ if (await db.Clients.AnyAsync(client => client.ClientId == settings.ClientId && client.Id != id, ct))
+ throw Conflict("Cet identifiant client existe déjà.");
+ var client = id.HasValue
+ ? await ClientQuery.SingleOrDefaultAsync(client => client.Id == id, ct) ?? throw Missing()
+ : new Client { Created = DateTime.UtcNow };
+ ApplySettings(client, settings);
+ client.Updated = DateTime.UtcNow;
+ if (!id.HasValue)
+ {
+ client.AllowedGrantTypes = [new ClientGrantType { GrantType = "authorization_code" }];
+ client.AllowedScopes = [new ClientScope { Scope = "openid" }, new ClientScope { Scope = "profile" }];
+ db.Clients.Add(client);
+ }
+ await db.SaveChangesAsync(Actor, ct);
+ return await GetClientAsync(client.Id, ct) ?? throw Missing();
+ }
+
+ public async Task DeleteClientAsync(int id, CancellationToken ct)
+ {
+ var client = await ClientQuery.SingleOrDefaultAsync(client => client.Id == id, ct) ?? throw Missing();
+ db.Clients.Remove(client);
+ await db.SaveChangesAsync(Actor, ct);
+ }
+
+ public async Task AddClientEntryAsync(int id, ClientCollection collection, ClientEntry entry, CancellationToken ct)
+ {
+ var client = await ClientQuery.SingleOrDefaultAsync(client => client.Id == id, ct) ?? throw Missing();
+ if (ToClient(client).Entries.Any(row => row.Collection == collection && row.Key == entry.Key &&
+ (row.Value == entry.Value || collection == ClientCollection.Properties)))
+ throw Conflict("Cette entrée existe déjà.");
+ if (collection is ClientCollection.Claims or ClientCollection.Properties && string.IsNullOrWhiteSpace(entry.Key))
+ throw Invalid("Une clé est requise.");
+ if (collection is ClientCollection.RedirectUris or ClientCollection.PostLogoutRedirectUris or ClientCollection.CorsOrigins)
+ {
+ if (!Uri.TryCreate(entry.Value, UriKind.Absolute, out var uri) ||
+ uri.Scheme is "javascript" or "data" || !string.IsNullOrEmpty(uri.Fragment))
+ throw Invalid("Une URI absolue sans fragment est requise.");
+ if (collection == ClientCollection.CorsOrigins &&
+ (uri.Scheme is not ("http" or "https") || uri.AbsolutePath != "/" || uri.Query.Length != 0))
+ throw Invalid("Une origine HTTP(S) sans chemin est requise.");
+ }
+ object row = collection switch
+ {
+ ClientCollection.RedirectUris => new ClientRedirectUri { ClientId = id, RedirectUri = entry.Value },
+ ClientCollection.PostLogoutRedirectUris => new ClientPostLogoutRedirectUri { ClientId = id, PostLogoutRedirectUri = entry.Value },
+ ClientCollection.Scopes => new ClientScope { ClientId = id, Scope = entry.Value },
+ ClientCollection.GrantTypes => new ClientGrantType { ClientId = id, GrantType = entry.Value },
+ ClientCollection.CorsOrigins => new ClientCorsOrigin { ClientId = id, Origin = entry.Value },
+ ClientCollection.IdentityProviders => new ClientIdPRestriction { ClientId = id, Provider = entry.Value },
+ ClientCollection.Claims => new ClientClaim { ClientId = id, Type = entry.Key!, Value = entry.Value },
+ ClientCollection.Properties => new ClientProperty { ClientId = id, Key = entry.Key!, Value = entry.Value },
+ _ => throw Invalid("Collection inconnue.")
+ };
+ db.Add(row);
+ await db.SaveChangesAsync(Actor, ct);
+ }
+
+ public async Task DeleteClientEntryAsync(int id, ClientCollection collection, int rowId, CancellationToken ct)
+ {
+ var client = await ClientQuery.SingleOrDefaultAsync(client => client.Id == id, ct) ?? throw Missing();
+ object? row = collection switch
+ {
+ ClientCollection.RedirectUris => client.RedirectUris.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.PostLogoutRedirectUris => client.PostLogoutRedirectUris.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.Scopes => client.AllowedScopes.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.GrantTypes => client.AllowedGrantTypes.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.CorsOrigins => client.AllowedCorsOrigins.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.IdentityProviders => client.IdentityProviderRestrictions.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.Claims => client.Claims.SingleOrDefault(row => row.Id == rowId),
+ ClientCollection.Properties => client.Properties.SingleOrDefault(row => row.Id == rowId),
+ _ => throw Invalid("Collection inconnue.")
+ };
+ db.Remove(row ?? throw Missing());
+ await db.SaveChangesAsync(Actor, ct);
+ }
+
+ public async Task CreateSecretAsync(int id, ClientSecretRequest request, bool replace, CancellationToken ct)
+ {
+ var client = await db.Clients.Include(client => client.ClientSecrets).SingleOrDefaultAsync(client => client.Id == id, ct)
+ ?? throw Missing();
+ if (request.Expiration <= DateTimeOffset.UtcNow)
+ throw Invalid("L’expiration doit être dans le futur.");
+ var expiration = request.Expiration ?? (replace ? DateTimeOffset.UtcNow.AddDays(90) : null);
+ var raw = replace || string.IsNullOrWhiteSpace(request.Value)
+ ? Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
+ : request.Value;
+ if (replace)
+ db.ClientSecrets.RemoveRange(client.ClientSecrets);
+ var secret = new ClientSecret
+ {
+ ClientId = id, Type = "SharedSecret",
+ Value = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(raw))),
+ Description = request.Description, Created = DateTime.UtcNow, Expiration = expiration?.UtcDateTime
+ };
+ db.ClientSecrets.Add(secret);
+ await db.SaveChangesAsync(Actor, ct);
+ return new(secret.Id, raw, expiration);
+ }
+
+ public async Task DeleteSecretAsync(int id, int secretId, CancellationToken ct)
+ {
+ var secret = await db.ClientSecrets.SingleOrDefaultAsync(secret => secret.ClientId == id && secret.Id == secretId, ct)
+ ?? throw Missing();
+ db.ClientSecrets.Remove(secret);
+ await db.SaveChangesAsync(Actor, ct);
+ }
+
+ public async Task> GetScopesAsync(CancellationToken ct)
+ => (await db.ApiScopes.AsNoTracking().OrderBy(scope => scope.Name).ToListAsync(ct)).Select(ToScope).ToArray();
+
+ public async Task SaveScopeAsync(int? id, ApiScopeSettings settings, CancellationToken ct)
+ {
+ if (await db.ApiScopes.AnyAsync(scope => scope.Name == settings.Name && scope.Id != id, ct))
+ throw Conflict("Ce scope existe déjà.");
+ var scope = id.HasValue ? await db.ApiScopes.FindAsync([id.Value], ct) ?? throw Missing() : new ApiScope();
+ scope.Name = settings.Name;
+ scope.DisplayName = settings.DisplayName;
+ scope.Description = settings.Description;
+ scope.Enabled = settings.Enabled;
+ scope.Required = settings.Required;
+ scope.Emphasize = settings.Emphasize;
+ scope.ShowInDiscoveryDocument = settings.ShowInDiscoveryDocument;
+ if (!id.HasValue) db.ApiScopes.Add(scope);
+ await db.SaveChangesAsync(Actor, ct);
+ return ToScope(scope);
+ }
+
+ public async Task DeleteScopeAsync(int id, CancellationToken ct)
+ {
+ var scope = await db.ApiScopes.FindAsync([id], ct) ?? throw Missing();
+ db.ApiScopes.Remove(scope);
+ await db.SaveChangesAsync(Actor, ct);
+ }
+
+ public async Task> GetMailingTemplatesAsync(CancellationToken ct)
+ => (await db.MailingTemplate.AsNoTracking().OrderBy(template => template.Id).ToListAsync(ct))
+ .Select(template => new MailingTemplateSettings { Id = template.Id, Topic = template.Topic,
+ Body = template.Body, ReplyToAddress = template.ReplyToAddress, ToSend = (MailPeriodicity)template.ToSend }).ToArray();
+
+ public IReadOnlyList GetMailingCriteria() => UserPolicies.Criterias.Keys.Order().ToArray();
+
+ public async Task SaveMailingTemplateAsync(string? id, MailingTemplateSettings settings, CancellationToken ct)
+ {
+ if (id is not null && id != settings.Id) throw Invalid("L’identifiant du modèle ne peut pas changer.");
+ if (id is null && await db.MailingTemplate.AnyAsync(template => template.Id == settings.Id, ct))
+ throw Conflict("Ce modèle existe déjà.");
+ if (!UserPolicies.Criterias.ContainsKey(settings.Id))
+ throw Invalid("Le modèle doit correspondre à un critère de diffusion connu.");
+ var template = id is null ? new MailingTemplate { Id = settings.Id }
+ : await db.MailingTemplate.FindAsync([id], ct) ?? throw Missing();
+ template.Topic = settings.Topic;
+ template.Body = settings.Body;
+ template.ReplyToAddress = settings.ReplyToAddress;
+ template.ToSend = (Periodicity)settings.ToSend;
+ if (id is null) db.MailingTemplate.Add(template);
+ await db.SaveChangesAsync(Actor, ct);
+ return settings;
+ }
+
+ public async Task DeleteMailingTemplateAsync(string id, CancellationToken ct)
+ {
+ var template = await db.MailingTemplate.FindAsync([id], ct) ?? throw Missing();
+ db.MailingTemplate.Remove(template);
+ await db.SaveChangesAsync(Actor, ct);
+ }
+
+ private static DateTimeOffset Utc(DateTime date) => new(DateTime.SpecifyKind(date, DateTimeKind.Utc));
+
+ private static AdministrationScope ToScope(ApiScope scope) => new(scope.Id, new ApiScopeSettings
+ {
+ Name = scope.Name, DisplayName = scope.DisplayName, Description = scope.Description, Enabled = scope.Enabled,
+ Required = scope.Required, Emphasize = scope.Emphasize, ShowInDiscoveryDocument = scope.ShowInDiscoveryDocument
+ });
+
+ private static AdministrationClient ToClient(Client client)
+ {
+ var entries = new List();
+ entries.AddRange(client.RedirectUris.Select(row => new ClientEntryResponse(row.Id, ClientCollection.RedirectUris, null, row.RedirectUri)));
+ entries.AddRange(client.PostLogoutRedirectUris.Select(row => new ClientEntryResponse(row.Id, ClientCollection.PostLogoutRedirectUris, null, row.PostLogoutRedirectUri)));
+ entries.AddRange(client.AllowedScopes.Select(row => new ClientEntryResponse(row.Id, ClientCollection.Scopes, null, row.Scope)));
+ entries.AddRange(client.AllowedGrantTypes.Select(row => new ClientEntryResponse(row.Id, ClientCollection.GrantTypes, null, row.GrantType)));
+ entries.AddRange(client.AllowedCorsOrigins.Select(row => new ClientEntryResponse(row.Id, ClientCollection.CorsOrigins, null, row.Origin)));
+ entries.AddRange(client.IdentityProviderRestrictions.Select(row => new ClientEntryResponse(row.Id, ClientCollection.IdentityProviders, null, row.Provider)));
+ entries.AddRange(client.Claims.Select(row => new ClientEntryResponse(row.Id, ClientCollection.Claims, row.Type, row.Value)));
+ entries.AddRange(client.Properties.Select(row => new ClientEntryResponse(row.Id, ClientCollection.Properties, row.Key, row.Value)));
+ return new(client.Id, ReadSettings(client), entries,
+ client.ClientSecrets.Select(secret => new ClientSecretInfo(secret.Id, secret.Description,
+ Utc(secret.Created), secret.Expiration.HasValue ? Utc(secret.Expiration.Value) : null)).ToArray());
+ }
+
+ private static ClientSettings ReadSettings(Client client) => new()
+ {
+ ClientId = client.ClientId, ClientName = client.ClientName, Enabled = client.Enabled, Description = client.Description,
+ ClientUri = client.ClientUri, LogoUri = client.LogoUri, ProtocolType = client.ProtocolType,
+ RequireClientSecret = client.RequireClientSecret, RequireConsent = client.RequireConsent,
+ AllowRememberConsent = client.AllowRememberConsent, AlwaysIncludeUserClaimsInIdToken = client.AlwaysIncludeUserClaimsInIdToken,
+ RequirePkce = client.RequirePkce, AllowPlainTextPkce = client.AllowPlainTextPkce, RequireRequestObject = client.RequireRequestObject,
+ AllowAccessTokensViaBrowser = client.AllowAccessTokensViaBrowser, FrontChannelLogoutUri = client.FrontChannelLogoutUri,
+ FrontChannelLogoutSessionRequired = client.FrontChannelLogoutSessionRequired, BackChannelLogoutUri = client.BackChannelLogoutUri,
+ BackChannelLogoutSessionRequired = client.BackChannelLogoutSessionRequired, AllowOfflineAccess = client.AllowOfflineAccess,
+ IdentityTokenLifetime = client.IdentityTokenLifetime, AccessTokenLifetime = client.AccessTokenLifetime,
+ AuthorizationCodeLifetime = client.AuthorizationCodeLifetime, AbsoluteRefreshTokenLifetime = client.AbsoluteRefreshTokenLifetime,
+ SlidingRefreshTokenLifetime = client.SlidingRefreshTokenLifetime, ConsentLifetime = client.ConsentLifetime,
+ AccessTokenType = (ClientAccessTokenType)client.AccessTokenType,
+ RefreshTokenExpiration = (ClientRefreshTokenExpiration)client.RefreshTokenExpiration,
+ RefreshTokenUsage = (ClientRefreshTokenUsage)client.RefreshTokenUsage, UpdateAccessTokenClaimsOnRefresh = client.UpdateAccessTokenClaimsOnRefresh,
+ EnableLocalLogin = client.EnableLocalLogin, IncludeJwtId = client.IncludeJwtId, AlwaysSendClientClaims = client.AlwaysSendClientClaims,
+ ClientClaimsPrefix = client.ClientClaimsPrefix, PairWiseSubjectSalt = client.PairWiseSubjectSalt,
+ UserSsoLifetime = client.UserSsoLifetime, UserCodeType = client.UserCodeType, DeviceCodeLifetime = client.DeviceCodeLifetime,
+ AllowedIdentityTokenSigningAlgorithms = client.AllowedIdentityTokenSigningAlgorithms
+ };
+
+ private static void ApplySettings(Client client, ClientSettings settings)
+ {
+ client.ClientId = settings.ClientId; client.ClientName = settings.ClientName; client.Enabled = settings.Enabled;
+ client.Description = settings.Description; client.ClientUri = settings.ClientUri; client.LogoUri = settings.LogoUri;
+ client.ProtocolType = settings.ProtocolType; client.RequireClientSecret = settings.RequireClientSecret;
+ client.RequireConsent = settings.RequireConsent; client.AllowRememberConsent = settings.AllowRememberConsent;
+ client.AlwaysIncludeUserClaimsInIdToken = settings.AlwaysIncludeUserClaimsInIdToken;
+ client.RequirePkce = settings.RequirePkce; client.AllowPlainTextPkce = settings.AllowPlainTextPkce;
+ client.RequireRequestObject = settings.RequireRequestObject; client.AllowAccessTokensViaBrowser = settings.AllowAccessTokensViaBrowser;
+ client.FrontChannelLogoutUri = settings.FrontChannelLogoutUri; client.FrontChannelLogoutSessionRequired = settings.FrontChannelLogoutSessionRequired;
+ client.BackChannelLogoutUri = settings.BackChannelLogoutUri; client.BackChannelLogoutSessionRequired = settings.BackChannelLogoutSessionRequired;
+ client.AllowOfflineAccess = settings.AllowOfflineAccess; client.IdentityTokenLifetime = settings.IdentityTokenLifetime;
+ client.AccessTokenLifetime = settings.AccessTokenLifetime; client.AuthorizationCodeLifetime = settings.AuthorizationCodeLifetime;
+ client.AbsoluteRefreshTokenLifetime = settings.AbsoluteRefreshTokenLifetime; client.SlidingRefreshTokenLifetime = settings.SlidingRefreshTokenLifetime;
+ client.ConsentLifetime = settings.ConsentLifetime; client.AccessTokenType = (int)settings.AccessTokenType;
+ client.RefreshTokenExpiration = (int)settings.RefreshTokenExpiration; client.RefreshTokenUsage = (int)settings.RefreshTokenUsage;
+ client.UpdateAccessTokenClaimsOnRefresh = settings.UpdateAccessTokenClaimsOnRefresh; client.EnableLocalLogin = settings.EnableLocalLogin;
+ client.IncludeJwtId = settings.IncludeJwtId; client.AlwaysSendClientClaims = settings.AlwaysSendClientClaims;
+ client.ClientClaimsPrefix = settings.ClientClaimsPrefix; client.PairWiseSubjectSalt = settings.PairWiseSubjectSalt;
+ client.UserSsoLifetime = settings.UserSsoLifetime; client.UserCodeType = settings.UserCodeType;
+ client.DeviceCodeLifetime = settings.DeviceCodeLifetime; client.AllowedIdentityTokenSigningAlgorithms = settings.AllowedIdentityTokenSigningAlgorithms;
+ }
+}
diff --git a/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs b/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs
new file mode 100644
index 000000000..447595cec
--- /dev/null
+++ b/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs
@@ -0,0 +1,16 @@
+using Yavsc.ApiControllers.Administration;
+
+namespace Yavsc.Api.Services;
+
+public static class AdministrationServiceCollectionExtensions
+{
+ public static IServiceCollection AddSiteAdministration(this IServiceCollection services)
+ {
+ services.AddHttpContextAccessor();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddOpenApi("administration", options =>
+ options.ShouldInclude = description => description.GroupName == "administration");
+ return services;
+ }
+}
diff --git a/src/Yavsc.Api/Services/IAdministrationService.cs b/src/Yavsc.Api/Services/IAdministrationService.cs
new file mode 100644
index 000000000..ce818cd11
--- /dev/null
+++ b/src/Yavsc.Api/Services/IAdministrationService.cs
@@ -0,0 +1,34 @@
+using Yavsc.Abstract.Administration;
+
+namespace Yavsc.Api.Services;
+
+public interface IAdministrationService
+{
+ Task GetSummaryAsync(CancellationToken ct);
+ Task> GetRolesAsync(CancellationToken ct);
+ Task EnsureRolesAsync(CancellationToken ct);
+ Task> GetRoleMembersAsync(string roleId, CancellationToken ct);
+ Task SetRoleMembershipAsync(string roleId, string userId, bool member, CancellationToken ct);
+ Task> GetClientsAsync(CancellationToken ct);
+ Task GetClientAsync(int id, CancellationToken ct);
+ Task SaveClientAsync(int? id, ClientSettings settings, CancellationToken ct);
+ Task DeleteClientAsync(int id, CancellationToken ct);
+ Task AddClientEntryAsync(int id, ClientCollection collection, ClientEntry entry, CancellationToken ct);
+ Task DeleteClientEntryAsync(int id, ClientCollection collection, int rowId, CancellationToken ct);
+ Task CreateSecretAsync(int id, ClientSecretRequest request, bool replace, CancellationToken ct);
+ Task DeleteSecretAsync(int id, int secretId, CancellationToken ct);
+ Task> GetScopesAsync(CancellationToken ct);
+ Task SaveScopeAsync(int? id, ApiScopeSettings settings, CancellationToken ct);
+ Task DeleteScopeAsync(int id, CancellationToken ct);
+ Task> GetMailingTemplatesAsync(CancellationToken ct);
+ IReadOnlyList GetMailingCriteria();
+ Task SaveMailingTemplateAsync(string? id, MailingTemplateSettings settings, CancellationToken ct);
+ Task DeleteMailingTemplateAsync(string id, CancellationToken ct);
+ Task ExportAsync(CancellationToken ct);
+ Task ImportAsync(AdministrationTransfer transfer, CancellationToken ct);
+}
+
+public sealed class AdministrationOperationException(int statusCode, string message) : Exception(message)
+{
+ public int StatusCode { get; } = statusCode;
+}
diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj
index be545e5b0..c9a021b62 100644
--- a/src/Yavsc.Api/Yavsc.Api.csproj
+++ b/src/Yavsc.Api/Yavsc.Api.csproj
@@ -12,6 +12,7 @@
+
\ No newline at end of file
diff --git a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs
index 3af71d909..decafb33b 100644
--- a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs
+++ b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs
@@ -19,6 +19,8 @@ using Yavsc.Tests.Shared;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Interface;
using Yavsc.Server.Hubs;
+using Microsoft.AspNetCore.Identity;
+using Yavsc.Api.Services;
namespace Yavsc.Api.Test.Fixtures;
@@ -66,6 +68,10 @@ public sealed class ApiWebServerFixture : WebHostFixture
.AddApplicationPart(typeof(ActivityApiController).Assembly);
builder.Services.AddLocalization();
+ builder.Services.AddIdentityCore(options =>
+ options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType)
+ .AddRoles().AddEntityFrameworkStores();
+ builder.Services.AddSiteAdministration();
builder.Services.Configure(_ => { });
builder.Services.Configure(settings => settings.Authority = "example.test");
builder.Services.AddTransient();
@@ -111,6 +117,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
+ app.MapOpenApi().RequireAuthorization("AdministratorOnly");
using (var scope = app.Services.CreateScope())
{
diff --git a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs
index db7eb0898..0d1df8181 100644
--- a/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs
+++ b/src/test/Yavsc.Api.Tests/ResourceUsageApiControllerTests.cs
@@ -184,7 +184,7 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture
+{
+ private const string Root = "/api/v1/administration";
+
+ private HttpClient Client(string? role = "Administrator")
+ {
+ var http = new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true })
+ { BaseAddress = new Uri(fixture.BaseAddress) };
+ if (role is not null)
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
+ TestTokenIssuer.Issue("site-admin", "api", extraClaims: [new Claim(Yavsc.Constants.RoleClaimType, role)]));
+ return http;
+ }
+
+ [Fact]
+ public async Task Administration_openapi_is_available_only_to_administrators()
+ {
+ using var http = Client();
+ var document = await http.GetFromJsonAsync("/openapi/administration.json");
+ Assert.True(document.GetProperty("paths").TryGetProperty(Root + "/clients", out _));
+ Assert.True(document.GetProperty("paths").TryGetProperty(Root + "/transfer", out _));
+ using var moderator = Client("Moderator");
+ Assert.Equal(HttpStatusCode.Forbidden, (await moderator.GetAsync("/openapi/administration.json")).StatusCode);
+ }
+
+ [Theory]
+ [InlineData(null, HttpStatusCode.Unauthorized)]
+ [InlineData("Moderator", HttpStatusCode.Forbidden)]
+ [InlineData("Performer", HttpStatusCode.Forbidden)]
+ [InlineData("administrator", HttpStatusCode.Forbidden)]
+ public async Task Every_administration_route_is_restricted(string? role, HttpStatusCode expected)
+ {
+ using var http = Client(role);
+ var routes = new (HttpMethod Method, string Path)[]
+ {
+ (HttpMethod.Get, "summary"), (HttpMethod.Get, "roles"), (HttpMethod.Post, "roles/ensure"),
+ (HttpMethod.Get, "roles/role/members"), (HttpMethod.Put, "roles/role/members/user"),
+ (HttpMethod.Delete, "roles/role/members/user"), (HttpMethod.Get, "clients"),
+ (HttpMethod.Get, "clients/1"), (HttpMethod.Post, "clients"), (HttpMethod.Put, "clients/1"),
+ (HttpMethod.Delete, "clients/1"), (HttpMethod.Post, "clients/1/collections/Scopes"),
+ (HttpMethod.Delete, "clients/1/collections/Scopes/1"), (HttpMethod.Post, "clients/1/secrets"),
+ (HttpMethod.Post, "clients/1/secrets/regenerate"), (HttpMethod.Delete, "clients/1/secrets/1"),
+ (HttpMethod.Get, "scopes"), (HttpMethod.Get, "scopes/1"), (HttpMethod.Post, "scopes"),
+ (HttpMethod.Put, "scopes/1"), (HttpMethod.Delete, "scopes/1"),
+ (HttpMethod.Get, "mailing-criteria"), (HttpMethod.Get, "mailing-templates"),
+ (HttpMethod.Get, "mailing-templates/template"), (HttpMethod.Post, "mailing-templates"),
+ (HttpMethod.Put, "mailing-templates/template"), (HttpMethod.Delete, "mailing-templates/template"),
+ (HttpMethod.Get, "transfer"), (HttpMethod.Post, "transfer")
+ };
+ foreach (var route in routes)
+ {
+ using var request = new HttpRequestMessage(route.Method, $"{Root}/{route.Path}");
+ using var response = await http.SendAsync(request, TestContext.Current.CancellationToken);
+ Assert.Equal(expected, response.StatusCode);
+ }
+ }
+
+ [Fact]
+ public async Task Client_scalars_collections_and_secrets_round_trip_without_exposing_stored_secrets()
+ {
+ using var http = Client();
+ var settings = new ClientSettings { ClientId = "test-" + Guid.NewGuid().ToString("N"), ClientName = "Test client",
+ RequirePkce = true, AllowOfflineAccess = true, AccessTokenLifetime = 1200, UserSsoLifetime = 600 };
+ var created = await http.PostAsJsonAsync($"{Root}/clients", settings);
+ Assert.Equal(HttpStatusCode.Created, created.StatusCode);
+ Assert.NotNull(created.Headers.Location);
+ var client = (await created.Content.ReadFromJsonAsync())!;
+ Assert.Equal(settings, client.Settings);
+ var id = client.Id;
+ Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync($"{Root}/clients", settings)).StatusCode);
+ var edited = settings with { Description = "Edited", RequireConsent = true, ClientClaimsPrefix = "app_" };
+ Assert.Equal(HttpStatusCode.OK, (await http.PutAsJsonAsync($"{Root}/clients/{id}", edited)).StatusCode);
+ Assert.Equal(edited, (await http.GetFromJsonAsync($"{Root}/clients/{id}"))!.Settings);
+
+ foreach (var collection in Enum.GetValues())
+ {
+ var value = collection switch
+ {
+ ClientCollection.RedirectUris or ClientCollection.PostLogoutRedirectUris => "https://example.invalid/callback",
+ ClientCollection.CorsOrigins => "https://example.invalid",
+ _ => "test-value"
+ };
+ var entry = new ClientEntry { Key = collection is ClientCollection.Claims or ClientCollection.Properties ? "key" : null, Value = value };
+ var response = await http.PostAsJsonAsync($"{Root}/clients/{id}/collections/{collection}", entry);
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(HttpStatusCode.Conflict,
+ (await http.PostAsJsonAsync($"{Root}/clients/{id}/collections/{collection}", entry)).StatusCode);
+ client = (await http.GetFromJsonAsync($"{Root}/clients/{id}"))!;
+ var row = Assert.Single(client.Entries, row => row.Collection == collection && row.Value == value);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.DeleteAsync($"{Root}/clients/{id + 99999}/collections/{collection}/{row.Id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.DeleteAsync($"{Root}/clients/{id}/collections/{collection}/{row.Id}")).StatusCode);
+ }
+
+ var secretResponse = await http.PostAsJsonAsync($"{Root}/clients/{id}/secrets",
+ new ClientSecretRequest { Description = "Added by integration test", Expiration = DateTimeOffset.UtcNow.AddDays(1) });
+ Assert.Equal(HttpStatusCode.Created, secretResponse.StatusCode);
+ Assert.True(secretResponse.Headers.CacheControl?.NoStore);
+ var secret = (await secretResponse.Content.ReadFromJsonAsync())!;
+ using (var scope = fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var stored = await db.ClientSecrets.SingleAsync(row => row.Id == secret.Id);
+ Assert.Equal(Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(secret.Value))), stored.Value);
+ var payload = await http.GetStringAsync($"{Root}/clients/{id}");
+ Assert.DoesNotContain(secret.Value, payload);
+ Assert.DoesNotContain(stored.Value, payload);
+ }
+ var replacementResponse = await http.PostAsJsonAsync($"{Root}/clients/{id}/secrets/regenerate", new ClientSecretRequest());
+ Assert.Equal(HttpStatusCode.OK, replacementResponse.StatusCode);
+ var replacement = (await replacementResponse.Content.ReadFromJsonAsync())!;
+ client = (await http.GetFromJsonAsync($"{Root}/clients/{id}"))!;
+ Assert.Equal(replacement.Id, Assert.Single(client.Secrets).Id);
+ Assert.NotEqual(secret.Value, replacement.Value);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.DeleteAsync($"{Root}/clients/{id}/secrets/{replacement.Id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.DeleteAsync($"{Root}/clients/{id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"{Root}/clients/{id}")).StatusCode);
+ }
+
+ [Fact]
+ public async Task Scopes_and_mailing_templates_support_create_update_delete_and_validation()
+ {
+ using var http = Client();
+ var scopeSettings = new ApiScopeSettings { Name = "scope-" + Guid.NewGuid().ToString("N"), DisplayName = "Test" };
+ var response = await http.PostAsJsonAsync($"{Root}/scopes", scopeSettings);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ Assert.NotNull(response.Headers.Location);
+ var scope = (await response.Content.ReadFromJsonAsync())!;
+ var edited = scopeSettings with { Description = "Updated", Enabled = false, Required = true, Emphasize = true };
+ Assert.Equal(HttpStatusCode.OK, (await http.PutAsJsonAsync($"{Root}/scopes/{scope.Id}", edited)).StatusCode);
+ Assert.Equal(edited, (await http.GetFromJsonAsync($"{Root}/scopes/{scope.Id}"))!.Settings);
+ Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync($"{Root}/scopes", edited)).StatusCode);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.DeleteAsync($"{Root}/scopes/{scope.Id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"{Root}/scopes/{scope.Id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, (await http.PostAsJsonAsync($"{Root}/scopes", new ApiScopeSettings())).StatusCode);
+
+ var criteria = (await http.GetFromJsonAsync>($"{Root}/mailing-criteria"))!;
+ Assert.NotEmpty(criteria);
+ var template = new MailingTemplateSettings { Id = criteria[0], Topic = "Test topic", Body = "Test body",
+ ReplyToAddress = "test@example.invalid", ToSend = MailPeriodicity.Weekly };
+ response = await http.PostAsJsonAsync($"{Root}/mailing-templates", template);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ Assert.NotNull(response.Headers.Location);
+ var escapedId = Uri.EscapeDataString(template.Id);
+ var updated = template with { Topic = "Changed topic", ToSend = MailPeriodicity.Monthly };
+ Assert.Equal(HttpStatusCode.OK, (await http.PutAsJsonAsync($"{Root}/mailing-templates/{escapedId}", updated)).StatusCode);
+ Assert.Equal(updated, await http.GetFromJsonAsync($"{Root}/mailing-templates/{escapedId}"));
+ Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync($"{Root}/mailing-templates", template)).StatusCode);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.DeleteAsync($"{Root}/mailing-templates/{escapedId}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync($"{Root}/mailing-templates/{escapedId}")).StatusCode);
+ }
+
+ [Fact]
+ public async Task Roles_support_membership_and_protect_the_last_administrator()
+ {
+ using var http = Client();
+ Assert.Equal(HttpStatusCode.NoContent, (await http.PostAsync($"{Root}/roles/ensure", null)).StatusCode);
+ var roles = (await http.GetFromJsonAsync>($"{Root}/roles"))!;
+ Assert.Contains(roles, role => role.Name == "Administrator");
+ Assert.Contains(roles, role => role.Name == "Moderator");
+ var role = roles.Single(role => role.Name == "Moderator");
+ var adminRole = roles.Single(role => role.Name == "Administrator");
+ using var scope = fixture.Services.CreateScope();
+ var manager = scope.ServiceProvider.GetRequiredService>();
+ var user = new ApplicationUser { Id = Guid.NewGuid().ToString(), UserName = "role-" + Guid.NewGuid().ToString("N"),
+ Email = Guid.NewGuid().ToString("N") + "@example.invalid" };
+ Assert.True((await manager.CreateAsync(user)).Succeeded);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.PutAsync($"{Root}/roles/{role.Id}/members/{user.Id}", null)).StatusCode);
+ var members = (await http.GetFromJsonAsync>($"{Root}/roles/{role.Id}/members"))!;
+ Assert.Contains(members, member => member.Id == user.Id);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.DeleteAsync($"{Root}/roles/{role.Id}/members/{user.Id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NoContent, (await http.PutAsync($"{Root}/roles/{adminRole.Id}/members/{user.Id}", null)).StatusCode);
+ try
+ {
+ Assert.Equal(HttpStatusCode.Conflict, (await http.DeleteAsync($"{Root}/roles/{adminRole.Id}/members/{user.Id}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await http.PutAsync($"{Root}/roles/missing/members/{user.Id}", null)).StatusCode);
+ }
+ finally { await manager.RemoveFromRoleAsync(user, "Administrator"); }
+ Assert.NotNull(await http.GetFromJsonAsync($"{Root}/summary"));
+ }
+
+ [Fact]
+ public async Task Business_transfer_is_persistent_atomic_and_excludes_authentication_material()
+ {
+ using var http = Client();
+ var id = Guid.NewGuid().ToString("N");
+ var user = new UserBusinessData { Id = id, UserName = "transfer-" + id, FullName = "Transfer user",
+ Email = id + "@example.invalid" };
+ var parentCode = "parent-" + id;
+ var childCode = "child-" + id;
+ var tagName = "transfer-" + id;
+ long expectedTagId;
+ using (var scope = fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var firstTag = new Tag { Name = tagName };
+ var secondTag = new Tag { Name = tagName };
+ db.Tags.AddRange(firstTag, secondTag);
+ await db.SaveChangesAsync();
+ expectedTagId = Math.Min(firstTag.Id, secondTag.Id);
+ }
+ var transfer = new AdministrationTransfer
+ {
+ Users = [user], Activities = [
+ new ActivityBusinessData { Code = childCode, Name = "Child", ParentCode = parentCode },
+ new ActivityBusinessData { Code = parentCode, Name = "Parent" }],
+ Blogs = [new BlogBusinessData { Id = 7654321, AuthorId = id, Title = "Transferred post",
+ DateCreated = DateTimeOffset.UtcNow, Published = true, Tags = [tagName] }]
+ };
+ var response = await http.PostAsJsonAsync($"{Root}/transfer", transfer);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(new ImportResult(1, 1, 2), await response.Content.ReadFromJsonAsync());
+ response = await http.PostAsJsonAsync($"{Root}/transfer", transfer);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using (var scope = fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var saved = await db.Users.SingleAsync(row => row.Id == id);
+ Assert.Null(saved.PasswordHash);
+ Assert.False(saved.EmailConfirmed);
+ Assert.Empty(await db.UserRoles.Where(row => row.UserId == id).ToListAsync());
+ Assert.Equal(parentCode, (await db.Activities.SingleAsync(row => row.Code == childCode)).ParentCode);
+ Assert.True(await db.blogSpotPublications.AnyAsync(row => row.PostId == 7654321));
+ var post = await db.BlogSpot.Include(row => row.Tags).ThenInclude(row => row.Tag)
+ .SingleAsync(row => row.Id == 7654321);
+ Assert.Equal(expectedTagId, Assert.Single(post.Tags).Tag.Id);
+ Assert.Equal(2, await db.Tags.CountAsync(row => row.Name == tagName));
+ }
+ var exported = await http.GetStringAsync($"{Root}/transfer");
+ using (var json = JsonDocument.Parse(exported))
+ {
+ var account = json.RootElement.GetProperty("users").EnumerateArray().Single(row => row.GetProperty("id").GetString() == id);
+ Assert.False(account.TryGetProperty("passwordHash", out _));
+ Assert.False(account.TryGetProperty("securityStamp", out _));
+ Assert.False(account.TryGetProperty("roles", out _));
+ }
+ var invalid = transfer with
+ {
+ Users = [user with { FullName = "Must roll back" }],
+ Blogs = [transfer.Blogs[0] with { AuthorId = "missing-author" }]
+ };
+ response = await http.PostAsJsonAsync($"{Root}/transfer", invalid);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ using (var scope = fixture.Services.CreateScope())
+ Assert.Equal("Transfer user", (await scope.ServiceProvider.GetRequiredService()
+ .Users.SingleAsync(row => row.Id == id)).FullName);
+
+ var cycle = new AdministrationTransfer
+ {
+ Activities = [new ActivityBusinessData { Code = "cycle-" + id, Name = "Cycle", ParentCode = "cycle-" + id }]
+ };
+ Assert.Equal(HttpStatusCode.BadRequest, (await http.PostAsJsonAsync($"{Root}/transfer", cycle)).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest,
+ (await http.PostAsJsonAsync($"{Root}/transfer", transfer with { Users = [user, user] })).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest,
+ (await http.PostAsJsonAsync($"{Root}/transfer", transfer with { Blogs = [transfer.Blogs[0] with { CircleIds = [long.MaxValue] }] })).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest,
+ (await http.PostAsJsonAsync($"{Root}/transfer", new { unrelated = "not a transfer" })).StatusCode);
+ Assert.Equal(HttpStatusCode.Conflict,
+ (await http.PostAsJsonAsync($"{Root}/transfer", transfer with
+ { Users = [user with { Email = "changed-" + id + "@example.invalid" }] })).StatusCode);
+ }
+
+ [Fact]
+ public async Task Account_import_never_changes_credentials_confirmations_or_roles()
+ {
+ using var http = Client();
+ var id = Guid.NewGuid().ToString("N");
+ using var scope = fixture.Services.CreateScope();
+ var manager = scope.ServiceProvider.GetRequiredService>();
+ var user = new ApplicationUser { Id = id, UserName = "safe-" + id, Email = id + "@example.invalid" };
+ Assert.True((await manager.CreateAsync(user, "A1!" + Guid.NewGuid().ToString("N"))).Succeeded);
+ var hash = user.PasswordHash;
+ var stamp = user.SecurityStamp;
+ var response = await http.PostAsJsonAsync($"{Root}/transfer", new
+ {
+ version = 1,
+ users = new[] { new { id, userName = user.UserName, email = user.Email, fullName = "Business update",
+ passwordHash = "must-not-be-imported", securityStamp = "must-not-be-imported",
+ emailConfirmed = true, roles = new[] { "Administrator" } } },
+ blogs = Array.Empty(), activities = Array.Empty()
+ });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var verification = fixture.Services.CreateScope();
+ var db = verification.ServiceProvider.GetRequiredService();
+ var saved = await db.Users.SingleAsync(row => row.Id == id);
+ Assert.Equal("Business update", saved.FullName);
+ Assert.Equal(hash, saved.PasswordHash);
+ Assert.Equal(stamp, saved.SecurityStamp);
+ Assert.False(saved.EmailConfirmed);
+ Assert.False(await db.UserRoles.AnyAsync(row => row.UserId == id));
+ }
+}