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