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

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

View file

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

View file

@ -33,6 +33,7 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />

View file

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

View file

@ -16,12 +16,13 @@ namespace PostIt.Tests;
public class AdministrationAccessTests
{
[AvaloniaTheory]
[InlineData("Moderator", true)]
[InlineData("Administrator", false)]
[InlineData("Moderator", false)]
[InlineData("Administrator", true)]
[InlineData("administrator", false)]
[InlineData("Performer", false)]
[InlineData("moderator", false)]
[InlineData("", false)]
public async Task Home_administration_visibility_and_command_follow_the_moderator_role(string role, bool allowed)
public async Task Home_administration_visibility_and_command_follow_the_administrator_role(string role, bool allowed)
{
await using var api = new ProfileTransport(role);
var session = new SessionStatusViewModel { Api = api };
@ -35,7 +36,7 @@ public class AdministrationAccessTests
button => Equals(button.Content, "Administration"));
Assert.False(button.IsVisible);
await session.RefreshAsync();
Assert.Equal(allowed, session.IsModerator);
Assert.Equal(allowed, session.IsAdministrator);
Assert.Equal(allowed, button.IsVisible);
Assert.Equal(allowed, vm.OpenAdministration.CanExecute(null));
Assert.DoesNotContain(page.GetVisualDescendants().OfType<Button>(),
@ -55,32 +56,32 @@ public class AdministrationAccessTests
[Fact]
public async Task Late_role_response_cannot_restore_access_after_logout()
{
await using var api = new ProfileTransport("Moderator");
await using var api = new ProfileTransport("Administrator");
var pending = new TaskCompletionSource<Me>();
api.PendingProfile = pending.Task;
var session = new SessionStatusViewModel { Api = api };
var refresh = session.RefreshAsync();
session.Api = null;
session.Refresh();
pending.SetResult(new Me { Roles = ["Moderator"] });
pending.SetResult(new Me { Roles = ["Administrator"] });
await refresh;
Assert.False(session.IsModerator);
Assert.False(session.IsAdministrator);
}
[Fact]
public async Task Role_lookup_failure_denies_access_and_reports_the_error()
{
await using var api = new ProfileTransport("Moderator") { FailProfile = true };
await using var api = new ProfileTransport("Administrator") { FailProfile = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
Assert.False(session.IsModerator);
Assert.False(session.IsAdministrator);
Assert.Contains("Impossible de vérifier les droits", session.SessionLabel);
}
[Fact]
public async Task Administration_loads_users_and_usage_and_matches_by_id()
{
await using var api = new ProfileTransport("Moderator");
await using var api = new ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
@ -96,7 +97,7 @@ public class AdministrationAccessTests
[AvaloniaFact]
public async Task Administration_displays_both_tabs_and_selected_user_usage()
{
await using var api = new ProfileTransport("Moderator");
await using var api = new ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
@ -109,7 +110,7 @@ public class AdministrationAccessTests
window.Show();
Dispatcher.UIThread.RunJobs();
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
Assert.Equal(2, tabs.Items.Count);
Assert.Equal(7, tabs.Items.Count);
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(),
text => text.Text == "Appels API : 12");
tabs.SelectedIndex = 1;
@ -126,9 +127,9 @@ public class AdministrationAccessTests
}
[Fact]
public async Task Administration_denies_non_moderators_without_requesting_private_data()
public async Task Administration_denies_moderators_without_requesting_private_data()
{
await using var api = new ProfileTransport("Administrator");
await using var api = new ProfileTransport("Moderator");
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
api.Requests.Clear();
@ -136,13 +137,13 @@ public class AdministrationAccessTests
await vm.RefreshAsync();
Assert.Empty(api.Requests);
Assert.Empty(vm.Users);
Assert.Contains("Moderator", vm.StatusMessage);
Assert.Contains("Administrator", vm.StatusMessage);
}
[Fact]
public async Task Administration_failure_reports_error_without_falling_back_to_personal_usage()
{
await using var api = new ProfileTransport("Moderator") { FailUsage = true };
await using var api = new ProfileTransport("Administrator") { FailUsage = true };
var session = new SessionStatusViewModel { Api = api };
await session.RefreshAsync();
var vm = new AdministrationPageViewModel(api, api.UsageClient, session);
@ -179,7 +180,7 @@ public class AdministrationAccessTests
Assert.Contains("Impossible de charger mon usage", vm.UsageError);
}
private sealed class ProfileTransport : YavscApiClient
internal sealed class ProfileTransport : YavscApiClient
{
private readonly string _tokenPath;
private readonly string _role;

View file

@ -0,0 +1,227 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http;
using System.Text.Json;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Administration;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class AdministrationToolsTests
{
[AvaloniaTheory]
[InlineData(400, 800)]
[InlineData(800, 400)]
public void All_administration_tabs_and_typed_forms_are_accessible_in_portrait_and_landscape(int width, int height)
{
var vm = new AdministrationPageViewModel();
var page = new AdministrationPage { DataContext = vm };
var window = new Window { Content = page, Width = width, Height = height };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var tabs = Assert.Single(page.GetVisualDescendants().OfType<TabControl>());
for (var index = 0; index < tabs.Items.Count; ++index)
{
var tab = Assert.IsType<TabItem>(tabs.ContainerFromIndex(index));
var point = tab.TranslatePoint(new Point(), page);
Assert.NotNull(point);
Assert.True(point.Value.X >= 0 && point.Value.X + tab.Bounds.Width <= page.Bounds.Width + 0.5);
}
tabs.SelectedIndex = 3;
Dispatcher.UIThread.RunJobs();
Assert.Contains(page.GetVisualDescendants().OfType<TextBlock>(), block => block.Text == "RequirePkce");
Assert.Contains(page.GetVisualDescendants().OfType<TextBox>(),
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ClientId");
tabs.SelectedIndex = 5;
Dispatcher.UIThread.RunJobs();
Assert.Contains(page.GetVisualDescendants().OfType<ComboBox>(),
box => box.DataContext is AdministrationFieldViewModel field && field.Name == "ToSend");
}
finally { window.Close(); }
}
[Fact]
public void Client_form_retains_every_scalar_field_and_validates_numeric_input()
{
var settings = new ClientSettings { ClientId = "postit", ClientName = "PostIt", AccessTokenLifetime = 900,
ConsentLifetime = null, RequireConsent = true, PairWiseSubjectSalt = null };
var fields = AdministrationFieldViewModel.From(settings);
Assert.Equal(typeof(ClientSettings).GetProperties().Length, fields.Count);
Assert.Equal(settings, AdministrationFieldViewModel.Build<ClientSettings>(fields));
var lifetime = fields.Single(field => field.Name == nameof(ClientSettings.AccessTokenLifetime));
lifetime.Text = "not-a-number";
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
lifetime.Text = "-1";
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<ClientSettings>(fields));
}
[Fact]
public void Mailing_form_round_trips_enum_and_multiline_body()
{
var settings = new MailingTemplateSettings { Id = "template", Topic = "Topic", Body = "First\nSecond",
ReplyToAddress = "reply@example.invalid", ToSend = MailPeriodicity.Monthly };
var fields = AdministrationFieldViewModel.From(settings);
Assert.Equal(settings, AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
Assert.True(fields.Single(field => field.Name == "Body").IsMultiline);
var periodicity = fields.Single(field => field.Name == "ToSend");
Assert.True(periodicity.IsChoice);
periodicity.Text = "Unknown";
Assert.Throws<ValidationException>(() => AdministrationFieldViewModel.Build<MailingTemplateSettings>(fields));
}
[Fact]
public async Task Client_commands_save_typed_data_and_require_confirmation_for_delete()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport();
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1"));
vm.ClientFields.Single(field => field.Name == "ClientId").Text = "new-client";
await vm.SaveClientCommand.ExecuteAsync(null);
Assert.Equal("new-client", transport.SavedClient?.ClientId);
Assert.Equal(1, vm.SelectedClient?.Id);
transport.Paths.Clear();
await vm.DeleteClientCommand.ExecuteAsync(null);
Assert.Empty(transport.Paths);
Assert.Contains("confirmation", vm.StatusMessage);
vm.ConfirmDestructiveAction = true;
await vm.DeleteClientCommand.ExecuteAsync(null);
Assert.Contains("DELETE https://example.invalid/api/v1/administration/clients/1", transport.Paths);
Assert.False(vm.ConfirmDestructiveAction);
Assert.Null(vm.SelectedClient);
}
[Fact]
public async Task Secrets_are_shown_once_and_cleared_on_selection_and_logout_denies_operations()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport();
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
await vm.AddSecretCommand.ExecuteAsync(null);
Assert.Equal("generated-test-value", vm.NewSecretValue);
Assert.Equal("", vm.SecretInput);
vm.NewClientCommand.Execute(null);
Assert.Null(vm.NewSecretValue);
session.Api = null;
session.Refresh();
transport.Paths.Clear();
await vm.AddSecretCommand.ExecuteAsync(null);
Assert.Empty(transport.Paths);
Assert.Contains("Administrator", vm.StatusMessage);
}
[Fact]
public async Task Secret_response_received_after_logout_is_discarded()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport
{
PendingSecret = new TaskCompletionSource<NewClientSecret>(TaskCreationOptions.RunContinuationsAsynchronously)
};
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
vm.SelectedClient = new AdministrationClient(1, new ClientSettings { ClientId = "client" }, [], []);
var operation = vm.AddSecretCommand.ExecuteAsync(null);
Assert.True(vm.IsBusy);
session.Api = null;
session.Refresh();
transport.PendingSecret.SetResult(new NewClientSecret(1, "must-not-be-shown", null));
await operation;
Assert.Null(vm.NewSecretValue);
Assert.Empty(vm.Clients);
Assert.Null(vm.SelectedClient);
Assert.Contains("Administrator", vm.StatusMessage);
Assert.Equal(["POST https://example.invalid/api/v1/administration/clients/1/secrets"], transport.Paths);
}
[Fact]
public async Task Refresh_without_administrator_access_clears_previous_administration_data()
{
var vm = new AdministrationPageViewModel();
var client = new AdministrationClient(1, new ClientSettings { ClientId = "previous-client" }, [], []);
vm.Clients.Add(client);
vm.SelectedClient = client;
vm.NewSecretValue = "previous-secret";
await vm.RefreshAsync();
Assert.Empty(vm.Clients);
Assert.Null(vm.SelectedClient);
Assert.Null(vm.NewSecretValue);
Assert.Equal("", vm.ClientFields.Single(field => field.Name == "ClientId").Text);
Assert.Contains("Administrator", vm.StatusMessage);
}
[Fact]
public async Task Import_requires_confirmation_and_uses_only_administration_host()
{
await using var profile = new AdministrationAccessTests.ProfileTransport("Administrator");
var session = new SessionStatusViewModel { Api = profile };
await session.RefreshAsync();
await using var transport = new AdminTransport();
var vm = new AdministrationPageViewModel(null, session: session,
administrationClient: new AdministrationApiClient(transport, () => "https://example.invalid/api/v1/"));
await Assert.ThrowsAsync<InvalidOperationException>(() => vm.ImportTransferAsync(new AdministrationTransfer()));
Assert.Empty(transport.Paths);
vm.ConfirmDestructiveAction = true;
var result = await vm.ImportTransferAsync(new AdministrationTransfer());
Assert.Equal(new ImportResult(0, 0, 0), result);
Assert.False(vm.ConfirmDestructiveAction);
Assert.Equal(["POST https://example.invalid/api/v1/administration/transfer"], transport.Paths);
}
private sealed class AdminTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = [];
public ClientSettings? SavedClient { get; private set; }
public TaskCompletionSource<NewClientSecret>? PendingSecret { get; init; }
public async Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add($"{method} {path}");
object result;
if (typeof(T) == typeof(AdministrationClient))
{
SavedClient = Assert.IsType<ClientSettings>(body);
result = new AdministrationClient(1, SavedClient, [], []);
}
else if (typeof(T) == typeof(List<AdministrationClient>))
result = new List<AdministrationClient> { new(1, SavedClient ?? new ClientSettings { ClientId = "client" }, [], []) };
else if (typeof(T) == typeof(NewClientSecret))
result = PendingSecret is null
? new NewClientSecret(1, "generated-test-value", null)
: await PendingSecret.Task;
else if (typeof(T) == typeof(ImportResult))
result = new ImportResult(0, 0, 0);
else
throw new NotSupportedException(typeof(T).Name);
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(result))!;
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add($"{method} {path}");
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> throw new NotSupportedException();
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -103,6 +104,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(administrationClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);

View file

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
public partial class AdministrationFieldViewModel : ObservableObject
{
private readonly Type _type;
private readonly bool _wasNull;
public string Name { get; }
public bool IsBoolean => _type == typeof(bool);
public bool IsChoice => _type.IsEnum;
public bool IsText => !IsBoolean && !IsChoice;
public bool IsMultiline => Name is "Body" or "Description";
public string[] Choices => IsChoice ? Enum.GetNames(_type) : [];
[ObservableProperty] public partial string Text { get; set; } = "";
[ObservableProperty] public partial bool Checked { get; set; }
private AdministrationFieldViewModel(string name, Type type, object? value)
{
Name = name;
_type = Nullable.GetUnderlyingType(type) ?? type;
_wasNull = value is null;
Checked = value is true;
Text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
}
public static IReadOnlyList<AdministrationFieldViewModel> From<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T record) where T : class
=> typeof(T).GetProperties().Select(property =>
new AdministrationFieldViewModel(property.Name, property.PropertyType, property.GetValue(record))).ToArray();
public static T Build<T>(IEnumerable<AdministrationFieldViewModel> fields) where T : class
{
var values = fields.ToDictionary(field => field.Name, field => field.ReadValue());
var record = JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(values))
?? throw new InvalidOperationException("Le formulaire est vide.");
var errors = new List<ValidationResult>();
if (!Validator.TryValidateObject(record, new ValidationContext(record), errors, true))
throw new ValidationException(string.Join("; ", errors.Select(error => error.ErrorMessage)));
return record;
}
private object? ReadValue()
{
if (IsBoolean) return Checked;
if (_type == typeof(string)) return _wasNull && Text.Length == 0 ? null : Text;
if (IsChoice)
{
if (!Choices.Contains(Text, StringComparer.Ordinal))
throw new ValidationException($"{Name} : choisissez une valeur valide.");
return Text;
}
if (_type == typeof(int))
{
if (string.IsNullOrWhiteSpace(Text)) return null;
if (int.TryParse(Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) return number;
throw new ValidationException($"{Name} : un entier est requis.");
}
throw new InvalidOperationException($"Type de champ non pris en charge : {Name}.");
}
}

View file

@ -0,0 +1,334 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Administration;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class AdministrationPageViewModel
{
[ObservableProperty] public partial AdministrationSummary? Summary { get; set; }
[ObservableProperty] public partial AdministrationRole? SelectedRole { get; set; }
[ObservableProperty] public partial RoleMember? SelectedMember { get; set; }
[ObservableProperty] public partial string EnrollUserId { get; set; } = "";
[ObservableProperty] public partial AdministrationClient? SelectedClient { get; set; }
[ObservableProperty] public partial AdministrationScope? SelectedScope { get; set; }
[ObservableProperty] public partial MailingTemplateSettings? SelectedMailingTemplate { get; set; }
[ObservableProperty] public partial ClientCollection SelectedCollection { get; set; }
[ObservableProperty] public partial ClientEntryResponse? SelectedEntry { get; set; }
[ObservableProperty] public partial ClientSecretInfo? SelectedSecret { get; set; }
[ObservableProperty] public partial string EntryKey { get; set; } = "";
[ObservableProperty] public partial string EntryValue { get; set; } = "";
[ObservableProperty] public partial string SecretInput { get; set; } = "";
[ObservableProperty] public partial string SecretDescription { get; set; } = "";
[ObservableProperty] public partial string SecretExpiration { get; set; } = "";
[ObservableProperty] public partial string? NewSecretValue { get; set; }
[ObservableProperty] public partial bool ConfirmDestructiveAction { get; set; }
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> ClientFields { get; set; } = [];
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> ScopeFields { get; set; } = [];
[ObservableProperty] public partial IReadOnlyList<AdministrationFieldViewModel> MailingFields { get; set; } = [];
public ObservableCollection<AdministrationRole> Roles { get; } = [];
public ObservableCollection<RoleMember> RoleMembers { get; } = [];
public ObservableCollection<AdministrationClient> Clients { get; } = [];
public ObservableCollection<AdministrationScope> Scopes { get; } = [];
public ObservableCollection<MailingTemplateSettings> MailingTemplates { get; } = [];
public ObservableCollection<string> MailingCriteria { get; } = [];
public ClientCollection[] Collections { get; } = Enum.GetValues<ClientCollection>();
private AdministrationApiClient RequireAccess()
{
if (_session?.IsAdministrator != true)
throw new UnauthorizedAccessException("Le rôle Administrator est requis.");
return _administrationClient ?? throw new InvalidOperationException("Client API d’administration indisponible.");
}
private async Task LoadToolsAsync()
{
var client = RequireAccess();
var summary = await client.GetSummaryAsync();
var roles = await client.GetRolesAsync();
var clients = await client.GetClientsAsync();
var scopes = await client.GetScopesAsync();
var templates = await client.GetMailingTemplatesAsync();
var criteria = await client.GetMailingCriteriaAsync();
RequireAccess();
Summary = summary;
Replace(Roles, roles);
Replace(Clients, clients);
Replace(Scopes, scopes);
Replace(MailingTemplates, templates);
Replace(MailingCriteria, criteria);
SelectedRole = null;
RoleMembers.Clear();
SelectedClient = null;
SelectedScope = null;
SelectedMailingTemplate = null;
}
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> source)
{
target.Clear();
foreach (var item in source) target.Add(item);
}
private async Task RunOperationAsync(Func<AdministrationApiClient, Task> operation)
{
if (IsBusy)
{
StatusMessage = "Une opération d’administration est déjà en cours.";
return;
}
IsBusy = true;
NewSecretValue = null;
try
{
var client = RequireAccess();
await operation(client);
RequireAccess();
StatusMessage = "Opération effectuée.";
}
catch (Exception ex)
{
if (_session?.IsAdministrator != true)
ClearAdministrationData();
StatusMessage = $"Administration : {ex.Message}";
}
finally
{
SecretInput = "";
ConfirmDestructiveAction = false;
IsBusy = false;
}
}
private void RequireConfirmation()
{
if (!ConfirmDestructiveAction)
throw new InvalidOperationException("Cochez la confirmation avant cette opération destructive.");
}
partial void OnSelectedClientChanged(AdministrationClient? value)
{
ClientFields = AdministrationFieldViewModel.From(value?.Settings ?? new ClientSettings());
SelectedEntry = null;
SelectedSecret = null;
NewSecretValue = null;
SecretInput = "";
ConfirmDestructiveAction = false;
}
partial void OnSelectedScopeChanged(AdministrationScope? value)
=> ScopeFields = AdministrationFieldViewModel.From(value?.Settings ?? new ApiScopeSettings());
partial void OnSelectedMailingTemplateChanged(MailingTemplateSettings? value)
=> MailingFields = AdministrationFieldViewModel.From(value ?? new MailingTemplateSettings());
[RelayCommand]
private void NewClient()
{
SelectedClient = null;
ClientFields = AdministrationFieldViewModel.From(new ClientSettings());
}
[RelayCommand]
private void NewScope()
{
SelectedScope = null;
ScopeFields = AdministrationFieldViewModel.From(new ApiScopeSettings());
}
[RelayCommand]
private void NewMailingTemplate()
{
SelectedMailingTemplate = null;
MailingFields = AdministrationFieldViewModel.From(new MailingTemplateSettings());
}
[RelayCommand]
private Task EnsureRolesAsync() => RunOperationAsync(async client =>
{
await client.EnsureRolesAsync();
await ReloadRolesAsync(client);
});
private async Task ReloadRolesAsync(AdministrationApiClient client)
{
var selectedId = SelectedRole?.Id;
Replace(Roles, await client.GetRolesAsync());
SelectedRole = Roles.FirstOrDefault(role => role.Id == selectedId);
}
[RelayCommand]
private Task LoadRoleMembersAsync() => RunOperationAsync(async client =>
{
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
var members = await client.GetRoleMembersAsync(role.Id);
RequireAccess();
Replace(RoleMembers, members);
});
[RelayCommand]
private Task EnrollAsync() => RunOperationAsync(async client =>
{
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
var id = string.IsNullOrWhiteSpace(EnrollUserId) ? SelectedUser?.UserId : EnrollUserId.Trim();
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Sélectionnez un utilisateur ou indiquez son identifiant.");
await client.SetRoleMembershipAsync(role.Id, id, true);
Replace(RoleMembers, await client.GetRoleMembersAsync(role.Id));
await ReloadRolesAsync(client);
});
[RelayCommand]
private Task FireAsync() => RunOperationAsync(async client =>
{
RequireConfirmation();
var role = SelectedRole ?? throw new InvalidOperationException("Choisissez un rôle.");
var member = SelectedMember ?? throw new InvalidOperationException("Choisissez un membre.");
await client.SetRoleMembershipAsync(role.Id, member.Id, false);
Replace(RoleMembers, await client.GetRoleMembersAsync(role.Id));
await ReloadRolesAsync(client);
});
[RelayCommand]
private Task SaveClientAsync() => RunOperationAsync(async client =>
{
var saved = await client.SaveClientAsync(SelectedClient?.Id, AdministrationFieldViewModel.Build<ClientSettings>(ClientFields));
Replace(Clients, await client.GetClientsAsync());
SelectedClient = Clients.Single(item => item.Id == saved.Id);
});
[RelayCommand]
private Task DeleteClientAsync() => RunOperationAsync(async client =>
{
RequireConfirmation();
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
await client.DeleteClientAsync(selected.Id);
Replace(Clients, await client.GetClientsAsync());
NewClient();
});
private async Task ReloadClientAsync(AdministrationApiClient client, int id)
{
var clients = await client.GetClientsAsync();
RequireAccess();
Replace(Clients, clients);
SelectedClient = Clients.Single(item => item.Id == id);
}
[RelayCommand]
private Task AddEntryAsync() => RunOperationAsync(async client =>
{
var selected = SelectedClient ?? throw new InvalidOperationException("Enregistrez d’abord le client.");
if (string.IsNullOrWhiteSpace(EntryValue)) throw new InvalidOperationException("La valeur est requise.");
await client.AddClientEntryAsync(selected.Id, SelectedCollection,
new ClientEntry { Key = string.IsNullOrWhiteSpace(EntryKey) ? null : EntryKey, Value = EntryValue });
await ReloadClientAsync(client, selected.Id);
EntryValue = "";
EntryKey = "";
});
[RelayCommand]
private Task DeleteEntryAsync() => RunOperationAsync(async client =>
{
RequireConfirmation();
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
var entry = SelectedEntry ?? throw new InvalidOperationException("Choisissez une entrée.");
await client.DeleteClientEntryAsync(selected.Id, entry.Collection, entry.Id);
await ReloadClientAsync(client, selected.Id);
});
private ClientSecretRequest SecretRequest()
{
DateTimeOffset? expiration = null;
if (!string.IsNullOrWhiteSpace(SecretExpiration))
{
if (!DateTimeOffset.TryParse(SecretExpiration, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
throw new InvalidOperationException("Expiration invalide : utilisez une date ISO 8601 avec fuseau.");
expiration = parsed;
}
return new() { Value = SecretInput, Description = SecretDescription, Expiration = expiration };
}
private Task CreateSecretAsync(bool replace) => RunOperationAsync(async client =>
{
if (replace) RequireConfirmation();
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
var secret = await client.CreateSecretAsync(selected.Id, SecretRequest(), replace);
RequireAccess();
await ReloadClientAsync(client, selected.Id);
NewSecretValue = secret.Value;
});
[RelayCommand] private Task AddSecretAsync() => CreateSecretAsync(false);
[RelayCommand] private Task RegenerateSecretAsync() => CreateSecretAsync(true);
[RelayCommand] private void ClearSecret() => NewSecretValue = null;
[RelayCommand]
private Task DeleteSecretAsync() => RunOperationAsync(async client =>
{
RequireConfirmation();
var selected = SelectedClient ?? throw new InvalidOperationException("Choisissez un client.");
var secret = SelectedSecret ?? throw new InvalidOperationException("Choisissez un secret.");
await client.DeleteSecretAsync(selected.Id, secret.Id);
await ReloadClientAsync(client, selected.Id);
});
[RelayCommand]
private Task SaveScopeAsync() => RunOperationAsync(async client =>
{
var saved = await client.SaveScopeAsync(SelectedScope?.Id, AdministrationFieldViewModel.Build<ApiScopeSettings>(ScopeFields));
Replace(Scopes, await client.GetScopesAsync());
SelectedScope = Scopes.Single(item => item.Id == saved.Id);
});
[RelayCommand]
private Task DeleteScopeAsync() => RunOperationAsync(async client =>
{
RequireConfirmation();
var selected = SelectedScope ?? throw new InvalidOperationException("Choisissez un scope.");
await client.DeleteScopeAsync(selected.Id);
Replace(Scopes, await client.GetScopesAsync());
NewScope();
});
[RelayCommand]
private Task SaveMailingTemplateAsync() => RunOperationAsync(async client =>
{
var saved = await client.SaveMailingTemplateAsync(SelectedMailingTemplate?.Id,
AdministrationFieldViewModel.Build<MailingTemplateSettings>(MailingFields));
Replace(MailingTemplates, await client.GetMailingTemplatesAsync());
SelectedMailingTemplate = MailingTemplates.Single(item => item.Id == saved.Id);
});
[RelayCommand]
private Task DeleteMailingTemplateAsync() => RunOperationAsync(async client =>
{
RequireConfirmation();
var selected = SelectedMailingTemplate ?? throw new InvalidOperationException("Choisissez un modèle.");
await client.DeleteMailingTemplateAsync(selected.Id);
Replace(MailingTemplates, await client.GetMailingTemplatesAsync());
NewMailingTemplate();
});
public async Task<AdministrationTransfer> ExportTransferAsync()
{
var data = await RequireAccess().ExportAsync();
RequireAccess();
return data;
}
public async Task<ImportResult> ImportTransferAsync(AdministrationTransfer transfer)
{
RequireConfirmation();
var result = await RequireAccess().ImportAsync(transfer);
ConfirmDestructiveAction = false;
return result;
}
}

View file

@ -10,6 +10,7 @@ using PostIt.Services;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
using Yavsc.Abstract.Administration;
namespace PostIt.ViewModels;
@ -18,6 +19,7 @@ public partial class AdministrationPageViewModel : ViewModelBase
private readonly YavscApiClient? _api;
private readonly ResourceUsageApiClient? _usageClient;
private readonly SessionStatusViewModel? _session;
private readonly AdministrationApiClient? _administrationClient;
public ObservableCollection<ResourceUsageSummary> UsageOverview { get; } = new();
@ -53,11 +55,16 @@ public partial class AdministrationPageViewModel : ViewModelBase
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public AdministrationPageViewModel(YavscApiClient? api,
ResourceUsageApiClient? usageClient = null, SessionStatusViewModel? session = null)
ResourceUsageApiClient? usageClient = null, SessionStatusViewModel? session = null,
AdministrationApiClient? administrationClient = null)
{
_api = api;
_usageClient = usageClient;
_session = session;
_administrationClient = administrationClient;
NewClient();
NewScope();
NewMailingTemplate();
}
public AdministrationPageViewModel() : this(null) { }
@ -74,14 +81,10 @@ public partial class AdministrationPageViewModel : ViewModelBase
[RelayCommand]
public async Task RefreshAsync()
{
Users.Clear();
UsageOverview.Clear();
SelectedUser = null;
OnPropertyChanged(nameof(FilteredUsers));
OnPropertyChanged(nameof(SelectedUsage));
if (_session?.IsModerator != true)
ClearAdministrationData();
if (_session?.IsAdministrator != true)
{
StatusMessage = "Le rôle Moderator est requis.";
StatusMessage = "Le rôle Administrator est requis.";
return;
}
if (_api is null || _usageClient is null)
@ -103,9 +106,9 @@ public partial class AdministrationPageViewModel : ViewModelBase
var usage = await _usageClient.GetAdminOverviewAsync().ConfigureAwait(true)
?? throw new InvalidOperationException("Le serveur a renvoyé une liste d’usages vide.");
if (!_session.IsModerator)
if (!_session.IsAdministrator)
{
StatusMessage = "Le rôle Moderator est requis.";
StatusMessage = "Le rôle Administrator est requis.";
return;
}
@ -118,12 +121,13 @@ public partial class AdministrationPageViewModel : ViewModelBase
OnPropertyChanged(nameof(FilteredUsers));
OnPropertyChanged(nameof(SelectedUsage));
if (_administrationClient is not null)
await LoadToolsAsync();
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
}
catch (Exception ex)
{
Users.Clear();
OnPropertyChanged(nameof(FilteredUsers));
ClearAdministrationData();
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
}
finally
@ -131,4 +135,33 @@ public partial class AdministrationPageViewModel : ViewModelBase
IsBusy = false;
}
}
private void ClearAdministrationData()
{
Users.Clear();
UsageOverview.Clear();
SelectedUser = null;
Summary = null;
Roles.Clear();
RoleMembers.Clear();
Clients.Clear();
Scopes.Clear();
MailingTemplates.Clear();
MailingCriteria.Clear();
SelectedRole = null;
SelectedMember = null;
NewClient();
NewScope();
NewMailingTemplate();
NewSecretValue = null;
SecretInput = "";
SecretDescription = "";
SecretExpiration = "";
EntryKey = "";
EntryValue = "";
EnrollUserId = "";
ConfirmDestructiveAction = false;
OnPropertyChanged(nameof(FilteredUsers));
OnPropertyChanged(nameof(SelectedUsage));
}
}

View file

@ -72,7 +72,7 @@ public class HomePageViewModel : ViewModelBase
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsModerator);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator);
OpenChat = new AsyncRelayCommand(OpenChatAsync);
}
@ -156,7 +156,7 @@ public class HomePageViewModel : ViewModelBase
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SessionStatusViewModel.IsModerator))
if (e.PropertyName == nameof(SessionStatusViewModel.IsAdministrator))
{
if (Dispatcher.UIThread.CheckAccess())
OpenAdministration.NotifyCanExecuteChanged();
@ -307,8 +307,8 @@ public class HomePageViewModel : ViewModelBase
private async Task OpenAdministrationAsync()
{
if (!SessionStatus.IsModerator)
throw new UnauthorizedAccessException("Le rôle Moderator est requis.");
if (!SessionStatus.IsAdministrator)
throw new UnauthorizedAccessException("Le rôle Administrator est requis.");
var app = (App?)Application.Current;
if (app is null)

View file

@ -40,7 +40,7 @@ public partial class SessionStatusViewModel : ViewModelBase
public partial bool IsLoggedIn { get; private set; }
[ObservableProperty]
public partial bool IsModerator { get; private set; }
public partial bool IsAdministrator { get; private set; }
private int _accessVersion;
@ -81,7 +81,7 @@ public partial class SessionStatusViewModel : ViewModelBase
public void Refresh()
{
++_accessVersion;
IsModerator = false;
IsAdministrator = false;
var has = Api?.HasValidSession ?? false;
IsLoggedIn = has;
IsLoggedOut = !has;
@ -103,7 +103,7 @@ public partial class SessionStatusViewModel : ViewModelBase
if (profile is null)
throw new InvalidOperationException("Le serveur a renvoyé un profil vide.");
if (version == _accessVersion)
IsModerator = profile.Roles.Contains(Yavsc.Constants.BlogModeratorGroupName, StringComparer.Ordinal);
IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminGroupName, StringComparer.Ordinal);
}
catch (Exception ex)
{
@ -122,7 +122,7 @@ public partial class SessionStatusViewModel : ViewModelBase
public void SetError(string message)
{
++_accessVersion;
IsModerator = false;
IsAdministrator = false;
IsLoggedIn = false;
IsLoggedOut = true;
SessionLabel = message;

View file

@ -2,10 +2,60 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:resources="using:Yavsc.Abstract.Resources"
xmlns:admin="using:Yavsc.Abstract.Administration"
x:Class="PostIt.Views.AdministrationPage"
x:DataType="vm:AdministrationPageViewModel"
Header="Administration">
<ContentPage.DataTemplates>
<DataTemplate DataType="{x:Type vm:AdministrationFieldViewModel}" x:DataType="vm:AdministrationFieldViewModel">
<StackPanel Spacing="4" Margin="0,0,0,10">
<TextBlock Text="{Binding Name}" TextWrapping="Wrap"/>
<TextBox Text="{Binding Text, Mode=TwoWay}" IsVisible="{Binding IsText}"
AcceptsReturn="{Binding IsMultiline}" TextWrapping="Wrap"/>
<CheckBox IsChecked="{Binding Checked, Mode=TwoWay}" IsVisible="{Binding IsBoolean}"/>
<ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding Text, Mode=TwoWay}"
IsVisible="{Binding IsChoice}" HorizontalAlignment="Stretch"/>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:AdministrationRole}" x:DataType="admin:AdministrationRole">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}"/>
<TextBlock Text="{Binding UserCount, StringFormat='({0} membres)'}"/>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:RoleMember}" x:DataType="admin:RoleMember">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName}" FontWeight="Bold"/>
<TextBlock Text="{Binding Id}"/>
<TextBlock Text="{Binding Email}"/>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:AdministrationClient}" x:DataType="admin:AdministrationClient">
<TextBlock Text="{Binding Settings.ClientId}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:AdministrationScope}" x:DataType="admin:AdministrationScope">
<TextBlock Text="{Binding Settings.Name}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:MailingTemplateSettings}" x:DataType="admin:MailingTemplateSettings">
<TextBlock Text="{Binding Id}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:ClientEntryResponse}" x:DataType="admin:ClientEntryResponse">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Collection}" FontWeight="Bold"/>
<TextBlock Text="{Binding Key}"/>
<TextBlock Text="{Binding Value}" TextWrapping="Wrap"/>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type admin:ClientSecretInfo}" x:DataType="admin:ClientSecretInfo">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Description}"/>
<TextBlock Text="{Binding Created, StringFormat='Création : {0:u}'}"/>
<TextBlock Text="{Binding Expiration, StringFormat='Expiration : {0:u}'}"/>
</StackPanel>
</DataTemplate>
</ContentPage.DataTemplates>
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Administration"
@ -13,6 +63,8 @@
FontWeight="SemiBold"/>
<StackPanel Grid.Row="1" Spacing="8">
<TextBlock Text="{Binding Summary.UserCount, StringFormat='Utilisateurs enregistrés : {0}'}"/>
<TextBlock Text="{Binding Summary.AdministratorCount, StringFormat='Administrateurs : {0}'}"/>
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
PlaceholderText="Rechercher un utilisateur..."/>
<Button Content="Actualiser"
@ -23,9 +75,16 @@
Height="8"/>
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"/>
<CheckBox Content="Je confirme la prochaine suppression, exclusion, régénération ou import."
IsChecked="{Binding ConfirmDestructiveAction, Mode=TwoWay}"/>
</StackPanel>
<TabControl Grid.Row="2">
<TabControl Grid.Row="2" IsEnabled="{Binding !IsBusy}">
<TabControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</TabControl.ItemsPanel>
<TabItem Header="Utilisateurs">
<Grid ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
<ListBox ItemsSource="{Binding FilteredUsers}"
@ -93,6 +152,108 @@
</ItemsControl>
</ScrollViewer>
</TabItem>
<TabItem Header="Rôles">
<ScrollViewer>
<StackPanel Spacing="12">
<TextBlock Text="Rôles et affectations" FontSize="18" FontWeight="Bold"/>
<Button Content="Initialiser les rôles standards" Command="{Binding EnsureRolesCommand}"/>
<ComboBox ItemsSource="{Binding Roles}" SelectedItem="{Binding SelectedRole, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
<Button Content="Afficher les membres" Command="{Binding LoadRoleMembersCommand}"/>
<ListBox ItemsSource="{Binding RoleMembers}" SelectedItem="{Binding SelectedMember, Mode=TwoWay}" Height="200"/>
<TextBlock Text="Identifiant à inscrire (ou utilisateur sélectionné dans l’onglet Utilisateurs)" TextWrapping="Wrap"/>
<TextBox Text="{Binding EnrollUserId, Mode=TwoWay}" PlaceholderText="Identifiant utilisateur"/>
<WrapPanel>
<Button Content="Inscrire" Command="{Binding EnrollCommand}" Margin="0,0,8,8"/>
<Button Content="Retirer le membre sélectionné" Command="{Binding FireCommand}" Margin="0,0,8,8"/>
</WrapPanel>
<TextBlock Text="{Binding Summary.Assembly}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Summary.Runtime}" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="Clients OIDC">
<ScrollViewer>
<StackPanel Spacing="12">
<ComboBox ItemsSource="{Binding Clients}" SelectedItem="{Binding SelectedClient, Mode=TwoWay}"
HorizontalAlignment="Stretch" PlaceholderText="Choisir un client"/>
<WrapPanel>
<Button Content="Nouveau" Command="{Binding NewClientCommand}" Margin="0,0,8,8"/>
<Button Content="Enregistrer" Command="{Binding SaveClientCommand}" Margin="0,0,8,8"/>
<Button Content="Supprimer" Command="{Binding DeleteClientCommand}" Margin="0,0,8,8"/>
</WrapPanel>
<Expander Header="Paramètres du client" IsExpanded="True" HorizontalAlignment="Stretch">
<ItemsControl ItemsSource="{Binding ClientFields}"/>
</Expander>
<TextBlock Text="Collections" FontSize="18" FontWeight="Bold"/>
<ListBox ItemsSource="{Binding SelectedClient.Entries}" SelectedItem="{Binding SelectedEntry, Mode=TwoWay}" Height="200"/>
<ComboBox ItemsSource="{Binding Collections}" SelectedItem="{Binding SelectedCollection, Mode=TwoWay}"/>
<TextBox Text="{Binding EntryKey, Mode=TwoWay}" PlaceholderText="Clé (claims et propriétés)"/>
<TextBox Text="{Binding EntryValue, Mode=TwoWay}" PlaceholderText="Valeur"/>
<WrapPanel>
<Button Content="Ajouter l’entrée" Command="{Binding AddEntryCommand}" Margin="0,0,8,8"/>
<Button Content="Supprimer l’entrée sélectionnée" Command="{Binding DeleteEntryCommand}" Margin="0,0,8,8"/>
</WrapPanel>
<TextBlock Text="Secrets" FontSize="18" FontWeight="Bold"/>
<ListBox ItemsSource="{Binding SelectedClient.Secrets}" SelectedItem="{Binding SelectedSecret, Mode=TwoWay}" Height="150"/>
<TextBox Text="{Binding SecretInput, Mode=TwoWay}" PasswordChar="●"
PlaceholderText="Secret fourni (vide : générer un secret)"/>
<TextBox Text="{Binding SecretDescription, Mode=TwoWay}" PlaceholderText="Description"/>
<TextBox Text="{Binding SecretExpiration, Mode=TwoWay}" PlaceholderText="Expiration ISO 8601 (facultative)"/>
<WrapPanel>
<Button Content="Ajouter un secret" Command="{Binding AddSecretCommand}" Margin="0,0,8,8"/>
<Button Content="Régénérer et remplacer tous les secrets" Command="{Binding RegenerateSecretCommand}" Margin="0,0,8,8"/>
<Button Content="Supprimer le secret sélectionné" Command="{Binding DeleteSecretCommand}" Margin="0,0,8,8"/>
</WrapPanel>
<TextBlock Text="Nouveau secret : copiez-le maintenant. Il ne sera plus consultable ensuite." TextWrapping="Wrap"/>
<TextBox Text="{Binding NewSecretValue}" IsReadOnly="True"/>
<Button Content="Masquer le nouveau secret" Command="{Binding ClearSecretCommand}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="Scopes API">
<ScrollViewer>
<StackPanel Spacing="12">
<ComboBox ItemsSource="{Binding Scopes}" SelectedItem="{Binding SelectedScope, Mode=TwoWay}"
HorizontalAlignment="Stretch" PlaceholderText="Choisir un scope"/>
<WrapPanel>
<Button Content="Nouveau" Command="{Binding NewScopeCommand}" Margin="0,0,8,8"/>
<Button Content="Enregistrer" Command="{Binding SaveScopeCommand}" Margin="0,0,8,8"/>
<Button Content="Supprimer" Command="{Binding DeleteScopeCommand}" Margin="0,0,8,8"/>
</WrapPanel>
<ItemsControl ItemsSource="{Binding ScopeFields}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="Courriels">
<ScrollViewer>
<StackPanel Spacing="12">
<ComboBox ItemsSource="{Binding MailingTemplates}" SelectedItem="{Binding SelectedMailingTemplate, Mode=TwoWay}"
HorizontalAlignment="Stretch" PlaceholderText="Choisir un modèle"/>
<WrapPanel>
<Button Content="Nouveau" Command="{Binding NewMailingTemplateCommand}" Margin="0,0,8,8"/>
<Button Content="Enregistrer" Command="{Binding SaveMailingTemplateCommand}" Margin="0,0,8,8"/>
<Button Content="Supprimer" Command="{Binding DeleteMailingTemplateCommand}" Margin="0,0,8,8"/>
</WrapPanel>
<TextBlock Text="Critères de diffusion disponibles (champ Id du modèle)" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding MailingCriteria}"/>
<ItemsControl ItemsSource="{Binding MailingFields}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="Données">
<ScrollViewer>
<StackPanel Spacing="12">
<TextBlock Text="Transferts de données métier" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="Comptes (données métier uniquement), billets et activités. Aucun mot de passe, jeton, rôle ou secret d’authentification n’est transféré."
TextWrapping="Wrap"/>
<TextBlock Text="L’import met à jour les identifiants existants et crée les nouvelles lignes, en une transaction. Les cercles référencés doivent déjà exister. Les nouveaux comptes n’ont pas de mot de passe."
TextWrapping="Wrap"/>
<Button Content="Exporter vers un fichier JSON" Click="Export_Click"/>
<Button Content="Importer un fichier JSON (16 Mo maximum)" Click="Import_Click"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</ContentPage>

View file

@ -1,4 +1,12 @@
using Avalonia.Controls;
using System;
using System.IO;
using System.Text.Json;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using PostIt.ViewModels;
using PostIt.Helpers;
using Yavsc.Abstract.Administration;
namespace PostIt.Views;
@ -7,5 +15,75 @@ public partial class AdministrationPage : ContentPage
public AdministrationPage()
{
InitializeComponent();
DetachedFromVisualTree += (_, _) =>
{
if (DataContext is AdministrationPageViewModel vm)
{
vm.NewSecretValue = null;
vm.SecretInput = "";
vm.ConfirmDestructiveAction = false;
}
};
}
private async void Export_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is not AdministrationPageViewModel vm || vm.IsBusy) return;
vm.IsBusy = true;
try
{
var data = await vm.ExportTransferAsync();
var content = JsonSerializer.SerializeToUtf8Bytes(data, new JsonSerializerOptions { WriteIndented = true });
if (!await FileSaveHelpers.SaveAsync("yavsc-business-data.json", "json", content)) return;
vm.StatusMessage = "Données métier exportées.";
}
catch (Exception ex)
{
vm.StatusMessage = $"Export impossible : {ex.Message}";
}
finally { vm.IsBusy = false; }
}
private async void Import_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is not AdministrationPageViewModel vm || vm.IsBusy) return;
vm.IsBusy = true;
try
{
if (!vm.ConfirmDestructiveAction) throw new InvalidOperationException("Confirmez l’import avant de choisir un fichier.");
var topLevel = TopLevel.GetTopLevel(this) ?? throw new InvalidOperationException("Sélecteur de fichiers indisponible.");
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Importer les données métier", AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }]
});
if (files.Count == 0) return;
const int limit = 16 * 1024 * 1024;
await using var stream = await files[0].OpenReadAsync();
using var data = new MemoryStream();
var buffer = new byte[8192];
int count;
while ((count = await stream.ReadAsync(buffer)) > 0)
{
if (data.Length + count > limit) throw new InvalidOperationException("Le fichier dépasse 16 Mo.");
await data.WriteAsync(buffer.AsMemory(0, count));
}
data.Position = 0;
var transfer = await JsonSerializer.DeserializeAsync<AdministrationTransfer>(data,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Le fichier est vide.");
var result = await vm.ImportTransferAsync(transfer);
await vm.RefreshAsync();
vm.StatusMessage = $"Import effectué : {result.Users} comptes, {result.Blogs} billets, {result.Activities} activités. {vm.StatusMessage}";
}
catch (Exception ex)
{
vm.StatusMessage = $"Import impossible : {ex.Message}";
}
finally
{
vm.ConfirmDestructiveAction = false;
vm.IsBusy = false;
}
}
}

View file

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

View file

@ -0,0 +1,192 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Yavsc.Abstract.Administration;
/// <summary>Site administration counts and runtime information.</summary>
public sealed record AdministrationSummary(int UserCount, int AdministratorCount, string Assembly, string Runtime);
/// <summary>A role with its current membership count.</summary>
public sealed record AdministrationRole(string Id, string Name, int UserCount);
/// <summary>A user belonging to an administration role.</summary>
public sealed record RoleMember(string Id, string UserName, string? Email);
/// <summary>Access token format understood by IdentityServer.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ClientAccessTokenType>))]
public enum ClientAccessTokenType { Jwt, Reference }
/// <summary>Refresh token reuse policy.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ClientRefreshTokenUsage>))]
public enum ClientRefreshTokenUsage { ReUse, OneTimeOnly }
/// <summary>Refresh token expiration policy.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ClientRefreshTokenExpiration>))]
public enum ClientRefreshTokenExpiration { Sliding, Absolute }
/// <summary>Editable OAuth/OIDC scalar settings, independent of persistence entities.</summary>
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; }
}
/// <summary>A client configuration collection supported by the web administration.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ClientCollection>))]
public enum ClientCollection
{
RedirectUris, PostLogoutRedirectUris, Scopes, GrantTypes, CorsOrigins,
IdentityProviders, Claims, Properties
}
/// <summary>An editable collection entry; Key is used for claims and properties.</summary>
public sealed record ClientEntry
{
public string? Key { get; init; }
[Required, MaxLength(4096)] public string Value { get; init; } = "";
}
/// <summary>A persisted collection row, without any credential material.</summary>
public sealed record ClientEntryResponse(int Id, ClientCollection Collection, string? Key, string Value);
/// <summary>Secret metadata; neither plaintext nor a stored hash is exposed.</summary>
public sealed record ClientSecretInfo(int Id, string? Description, DateTimeOffset Created, DateTimeOffset? Expiration);
/// <summary>A client and all its administrable collections.</summary>
public sealed record AdministrationClient(int Id, ClientSettings Settings,
IReadOnlyList<ClientEntryResponse> Entries, IReadOnlyList<ClientSecretInfo> Secrets);
/// <summary>Input for adding a shared secret or generating a replacement.</summary>
public sealed record ClientSecretRequest
{
[MaxLength(4096)] public string? Value { get; init; }
[MaxLength(2000)] public string? Description { get; init; }
public DateTimeOffset? Expiration { get; init; }
}
/// <summary>A newly generated client secret returned once, never available through GET.</summary>
public sealed record NewClientSecret(int Id, string Value, DateTimeOffset? Expiration);
/// <summary>Editable API scope settings.</summary>
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;
}
/// <summary>A persisted API scope.</summary>
public sealed record AdministrationScope(int Id, ApiScopeSettings Settings);
/// <summary>Mail delivery periodicity, compatible with the server calendar model.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<MailPeriodicity>))]
public enum MailPeriodicity { OnDemand = -1, Daily, Weekly, Monthly, ThreeM, SixM, Yearly }
/// <summary>Editable mail template settings.</summary>
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;
}
/// <summary>A safe account business-data transfer without credentials, confirmations or roles.</summary>
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;
}
/// <summary>A blog business-data transfer retaining publication and access-control references.</summary>
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; } = [];
}
/// <summary>An activity business-data transfer without navigation entities.</summary>
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; }
}
/// <summary>A versioned business-data transfer. No authentication secrets are part of this schema.</summary>
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; } = [];
}
/// <summary>Counts of rows successfully imported in a single transaction.</summary>
public sealed record ImportResult(int Users, int Blogs, int Activities);

View file

@ -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<string> 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<AdministrationSummary> GetSummaryAsync(CancellationToken ct = default)
=> api.CallAsync<AdministrationSummary>(HttpMethod.Get, Path("summary"), ct: ct);
public Task<List<AdministrationRole>> GetRolesAsync(CancellationToken ct = default)
=> api.CallAsync<List<AdministrationRole>>(HttpMethod.Get, Path("roles"), ct: ct);
public Task EnsureRolesAsync(CancellationToken ct = default)
=> api.CallAsync(HttpMethod.Post, Path("roles/ensure"), ct: ct);
public Task<List<RoleMember>> GetRoleMembersAsync(string roleId, CancellationToken ct = default)
=> api.CallAsync<List<RoleMember>>(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<List<AdministrationClient>> GetClientsAsync(CancellationToken ct = default)
=> api.CallAsync<List<AdministrationClient>>(HttpMethod.Get, Path("clients"), ct: ct);
public Task<AdministrationClient> SaveClientAsync(int? id, ClientSettings settings, CancellationToken ct = default)
=> api.CallAsync<AdministrationClient>(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<NewClientSecret> CreateSecretAsync(int id, ClientSecretRequest request, bool replace, CancellationToken ct = default)
=> api.CallAsync<NewClientSecret>(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<List<AdministrationScope>> GetScopesAsync(CancellationToken ct = default)
=> api.CallAsync<List<AdministrationScope>>(HttpMethod.Get, Path("scopes"), ct: ct);
public Task<AdministrationScope> SaveScopeAsync(int? id, ApiScopeSettings settings, CancellationToken ct = default)
=> api.CallAsync<AdministrationScope>(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<List<MailingTemplateSettings>> GetMailingTemplatesAsync(CancellationToken ct = default)
=> api.CallAsync<List<MailingTemplateSettings>>(HttpMethod.Get, Path("mailing-templates"), ct: ct);
public Task<List<string>> GetMailingCriteriaAsync(CancellationToken ct = default)
=> api.CallAsync<List<string>>(HttpMethod.Get, Path("mailing-criteria"), ct: ct);
public Task<MailingTemplateSettings> SaveMailingTemplateAsync(string? id, MailingTemplateSettings settings, CancellationToken ct = default)
=> api.CallAsync<MailingTemplateSettings>(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<AdministrationTransfer> ExportAsync(CancellationToken ct = default)
=> api.CallAsync<AdministrationTransfer>(HttpMethod.Get, Path("transfer"), ct: ct);
public Task<ImportResult> ImportAsync(AdministrationTransfer transfer, CancellationToken ct = default)
=> api.CallAsync<ImportResult>(HttpMethod.Post, Path("transfer"), transfer, ct);
}

View file

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

View file

@ -78,7 +78,7 @@ public sealed class ResourceUsageApiController : Controller
[HttpGet(Constants.ResourceUsageAdminRoute)]
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
{
if (!User.IsInRole(Constants.AdminGroupName) && !User.IsInRole(Constants.BlogModeratorGroupName))
if (!User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}

View file

@ -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<ProblemDetails>(400)]
[ProducesResponseType<ProblemDetails>(404)]
[ProducesResponseType<ProblemDetails>(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<AdministrationSummary>(200)]
public async Task<ActionResult<AdministrationSummary>> Summary(CancellationToken ct)
=> Ok(await service.GetSummaryAsync(ct));
[HttpGet("roles"), EndpointSummary("Lister les rôles et leurs effectifs")]
public async Task<ActionResult<IReadOnlyList<AdministrationRole>>> Roles(CancellationToken ct)
=> Ok(await service.GetRolesAsync(ct));
[HttpPost("roles/ensure"), EndpointSummary("Initialiser les rôles standards"), ProducesResponseType(204)]
public async Task<IActionResult> EnsureRoles(CancellationToken ct)
{
await service.EnsureRolesAsync(ct);
return NoContent();
}
[HttpGet("roles/{id}/members"), EndpointSummary("Lister les membres d’un rôle")]
public async Task<ActionResult<IReadOnlyList<RoleMember>>> 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<IActionResult> 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<IActionResult> 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<ActionResult<IReadOnlyList<AdministrationClient>>> Clients(CancellationToken ct)
=> Ok(await service.GetClientsAsync(ct));
[HttpGet("clients/{id:int}"), EndpointSummary("Consulter la configuration d’un client")]
[ProducesResponseType<AdministrationClient>(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<AdministrationClient>> 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<AdministrationClient>(201)]
public async Task<ActionResult<AdministrationClient>> 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<ActionResult<AdministrationClient>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<NewClientSecret>(201)]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<ActionResult<NewClientSecret>> 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<ActionResult<NewClientSecret>> 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<IActionResult> 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<ActionResult<IReadOnlyList<AdministrationScope>>> Scopes(CancellationToken ct)
=> Ok(await service.GetScopesAsync(ct));
[HttpGet("scopes/{id:int}"), EndpointSummary("Consulter un scope API")]
public async Task<ActionResult<AdministrationScope>> 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<AdministrationScope>(201)]
public async Task<ActionResult<AdministrationScope>> 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<ActionResult<AdministrationScope>> 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<IActionResult> 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<IReadOnlyList<string>> MailingCriteria(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
return Ok(service.GetMailingCriteria());
}
[HttpGet("mailing-templates"), EndpointSummary("Lister les modèles de courriels")]
public async Task<ActionResult<IReadOnlyList<MailingTemplateSettings>>> MailingTemplates(CancellationToken ct)
=> Ok(await service.GetMailingTemplatesAsync(ct));
[HttpGet("mailing-templates/{id}"), EndpointSummary("Consulter un modèle de courriel")]
public async Task<ActionResult<MailingTemplateSettings>> 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<MailingTemplateSettings>(201)]
public async Task<ActionResult<MailingTemplateSettings>> 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<ActionResult<MailingTemplateSettings>> 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<IActionResult> 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<ActionResult<AdministrationTransfer>> 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<ActionResult<ImportResult>> Import(AdministrationTransfer transfer, CancellationToken ct)
=> Ok(await service.ImportAsync(transfer, ct));
}
public sealed class AdministrationExceptionFilter(ILogger<AdministrationExceptionFilter> 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;
}
}

View file

@ -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<UserInfo> 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<UserInfo> SearchApplicationUser(string pattern, int skip=0, int take = 25)
{
var normalizedPattern = pattern ?? string.Empty;

View file

@ -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<AnthropicClient>(_ =>
@ -132,7 +134,10 @@ internal class Program
using (var app = builder.Build())
{
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.MapOpenApi().RequireAuthorization("AdministratorOnly");
}
app
.UseRouting()

View file

@ -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<AdministrationTransfer> 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<ImportResult> 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<string> { 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<ValidationResult>();
if (!Validator.TryValidateObject(value, new ValidationContext(value), errors, true))
throw Invalid(string.Join("; ", errors.Select(error => error.ErrorMessage)));
}
private static void Unique(IEnumerable<string> keys)
{
var values = keys.ToArray();
if (values.Distinct(StringComparer.Ordinal).Count() != values.Length)
throw Invalid("Le transfert contient des identifiants en double.");
}
}

View file

@ -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<ApplicationUser> users, RoleManager<IdentityRole> 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<AdministrationSummary> 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<IReadOnlyList<AdministrationRole>> GetRolesAsync(CancellationToken ct)
{
var all = await roles.Roles.OrderBy(role => role.Name).ToListAsync(ct);
var result = new List<AdministrationRole>();
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<IReadOnlyList<RoleMember>> 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<Client> 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<IReadOnlyList<AdministrationClient>> GetClientsAsync(CancellationToken ct)
=> (await ClientQuery.AsNoTracking().OrderBy(client => client.ClientId).ToListAsync(ct)).Select(ToClient).ToArray();
public async Task<AdministrationClient?> 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<AdministrationClient> 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<NewClientSecret> 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<IReadOnlyList<AdministrationScope>> GetScopesAsync(CancellationToken ct)
=> (await db.ApiScopes.AsNoTracking().OrderBy(scope => scope.Name).ToListAsync(ct)).Select(ToScope).ToArray();
public async Task<AdministrationScope> 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<IReadOnlyList<MailingTemplateSettings>> 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<string> GetMailingCriteria() => UserPolicies.Criterias.Keys.Order().ToArray();
public async Task<MailingTemplateSettings> 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<ClientEntryResponse>();
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;
}
}

View file

@ -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<IAdministrationService, AdministrationService>();
services.AddScoped<AdministrationExceptionFilter>();
services.AddOpenApi("administration", options =>
options.ShouldInclude = description => description.GroupName == "administration");
return services;
}
}

View file

@ -0,0 +1,34 @@
using Yavsc.Abstract.Administration;
namespace Yavsc.Api.Services;
public interface IAdministrationService
{
Task<AdministrationSummary> GetSummaryAsync(CancellationToken ct);
Task<IReadOnlyList<AdministrationRole>> GetRolesAsync(CancellationToken ct);
Task EnsureRolesAsync(CancellationToken ct);
Task<IReadOnlyList<RoleMember>> GetRoleMembersAsync(string roleId, CancellationToken ct);
Task SetRoleMembershipAsync(string roleId, string userId, bool member, CancellationToken ct);
Task<IReadOnlyList<AdministrationClient>> GetClientsAsync(CancellationToken ct);
Task<AdministrationClient?> GetClientAsync(int id, CancellationToken ct);
Task<AdministrationClient> 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<NewClientSecret> CreateSecretAsync(int id, ClientSecretRequest request, bool replace, CancellationToken ct);
Task DeleteSecretAsync(int id, int secretId, CancellationToken ct);
Task<IReadOnlyList<AdministrationScope>> GetScopesAsync(CancellationToken ct);
Task<AdministrationScope> SaveScopeAsync(int? id, ApiScopeSettings settings, CancellationToken ct);
Task DeleteScopeAsync(int id, CancellationToken ct);
Task<IReadOnlyList<MailingTemplateSettings>> GetMailingTemplatesAsync(CancellationToken ct);
IReadOnlyList<string> GetMailingCriteria();
Task<MailingTemplateSettings> SaveMailingTemplateAsync(string? id, MailingTemplateSettings settings, CancellationToken ct);
Task DeleteMailingTemplateAsync(string id, CancellationToken ct);
Task<AdministrationTransfer> ExportAsync(CancellationToken ct);
Task<ImportResult> ImportAsync(AdministrationTransfer transfer, CancellationToken ct);
}
public sealed class AdministrationOperationException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}

View file

@ -12,6 +12,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
</ItemGroup>
</Project>

View file

@ -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<ApplicationUser>(options =>
options.ClaimsIdentity.RoleClaimType = Yavsc.Constants.RoleClaimType)
.AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddSiteAdministration();
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>();
@ -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())
{

View file

@ -184,7 +184,7 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
}
[Theory]
[InlineData("Moderator", HttpStatusCode.OK)]
[InlineData("Moderator", HttpStatusCode.Forbidden)]
[InlineData("Administrator", HttpStatusCode.OK)]
[InlineData("Performer", HttpStatusCode.Forbidden)]
[InlineData("moderator", HttpStatusCode.Forbidden)]

View file

@ -0,0 +1,311 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Administration;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class SiteAdministrationApiTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
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<JsonElement>("/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<AdministrationClient>())!;
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<AdministrationClient>($"{Root}/clients/{id}"))!.Settings);
foreach (var collection in Enum.GetValues<ClientCollection>())
{
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<AdministrationClient>($"{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<NewClientSecret>())!;
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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<NewClientSecret>())!;
client = (await http.GetFromJsonAsync<AdministrationClient>($"{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<AdministrationScope>())!;
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<AdministrationScope>($"{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<List<string>>($"{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<MailingTemplateSettings>($"{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<List<AdministrationRole>>($"{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<UserManager<ApplicationUser>>();
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<List<RoleMember>>($"{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<AdministrationSummary>($"{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<ApplicationDbContext>();
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<ImportResult>());
response = await http.PostAsJsonAsync($"{Root}/transfer", transfer);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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<ApplicationDbContext>()
.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<UserManager<ApplicationUser>>();
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<object>(), activities = Array.Empty<object>()
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var verification = fixture.Services.CreateScope();
var db = verification.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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));
}
}