From 42b5eda460da1123f4a05344a0141a728cb7f025 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 9 Apr 2026 15:17:53 +0200 Subject: [PATCH] feat(19-02): add RegisterApp/RemoveApp commands, DI wiring, EN/FR localization - ProfileManagementViewModel: IAppRegistrationService injected, RegisterAppCommand/RemoveAppCommand added - IsRegistering, ShowFallbackInstructions, RegistrationStatus observable properties - HasRegisteredApp computed property, CanRegisterApp/CanRemoveApp guards - RegisterAppAsync: admin check, fallback panel, AppId persistence - RemoveAppAsync: removal + MSAL clear + AppId null + persistence - App.xaml.cs: IAppRegistrationService singleton registered - Strings.resx/fr.resx: 16 new localization keys for register/remove/fallback flow --- SharepointToolbox/App.xaml.cs | 3 + .../Localization/Strings.fr.resx | 17 +++ SharepointToolbox/Localization/Strings.resx | 17 +++ .../ViewModels/ProfileManagementViewModel.cs | 103 +++++++++++++++++- 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/SharepointToolbox/App.xaml.cs b/SharepointToolbox/App.xaml.cs index e1b1664..3ea3491 100644 --- a/SharepointToolbox/App.xaml.cs +++ b/SharepointToolbox/App.xaml.cs @@ -164,6 +164,9 @@ public partial class App : Application // Phase 18: Auto-Take Ownership services.AddTransient(); + // Phase 19: App Registration & Removal + services.AddSingleton(); + // Phase 7: User Access Audit services.AddTransient(); services.AddTransient(); diff --git a/SharepointToolbox/Localization/Strings.fr.resx b/SharepointToolbox/Localization/Strings.fr.resx index bddc5ae..428a59f 100644 --- a/SharepointToolbox/Localization/Strings.fr.resx +++ b/SharepointToolbox/Localization/Strings.fr.resx @@ -412,6 +412,23 @@ Options d'exportation Fusionner les permissions en double + + Enregistrer l'app + Supprimer l'app + Vérification des permissions... + Enregistrement de l'application... + Application enregistrée avec succès + L'enregistrement a échoué + Permissions insuffisantes pour l'enregistrement automatique + Suppression de l'application... + Application supprimée avec succès + Enregistrement manuel requis + 1. Allez dans le portail Azure > Inscriptions d'applications > Nouvelle inscription + 2. Nom : 'SharePoint Toolbox - {0}', Types de comptes : Locataire unique + 3. URI de redirection : Client public, https://login.microsoftonline.com/common/oauth2/nativeclient + 4. Sous Permissions API, ajouter : Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) et SharePoint (AllSites.FullControl) + 5. Accorder le consentement administrateur pour toutes les permissions + 6. Copier l'ID d'application (client) et le coller dans le champ ID Client ci-dessus Propriété du site Prendre automatiquement la propriété d'administrateur de collection de sites en cas de refus d'accès diff --git a/SharepointToolbox/Localization/Strings.resx b/SharepointToolbox/Localization/Strings.resx index 4d83e0a..883b62a 100644 --- a/SharepointToolbox/Localization/Strings.resx +++ b/SharepointToolbox/Localization/Strings.resx @@ -412,6 +412,23 @@ Export Options Merge duplicate permissions + + Register App + Remove App + Checking permissions... + Registering application... + Application registered successfully + Registration failed + Insufficient permissions for automatic registration + Removing application... + Application removed successfully + Manual Registration Required + 1. Go to Azure Portal > App registrations > New registration + 2. Name: 'SharePoint Toolbox - {0}', Supported account types: Single tenant + 3. Redirect URI: Public client, https://login.microsoftonline.com/common/oauth2/nativeclient + 4. Under API permissions, add: Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) and SharePoint (AllSites.FullControl) + 5. Grant admin consent for all permissions + 6. Copy the Application (client) ID and paste it in the Client ID field above Site Ownership Automatically take site collection admin ownership on access denied diff --git a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs index 38b1e6b..8b29d1b 100644 --- a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs +++ b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; @@ -16,6 +17,7 @@ public partial class ProfileManagementViewModel : ObservableObject private readonly IBrandingService _brandingService; private readonly AppGraphClientFactory _graphClientFactory; private readonly ILogger _logger; + private readonly IAppRegistrationService _appRegistrationService; [ObservableProperty] private TenantProfile? _selectedProfile; @@ -32,6 +34,17 @@ public partial class ProfileManagementViewModel : ObservableObject [ObservableProperty] private string _validationMessage = string.Empty; + [ObservableProperty] + private bool _isRegistering; + + [ObservableProperty] + private bool _showFallbackInstructions; + + [ObservableProperty] + private string _registrationStatus = string.Empty; + + public bool HasRegisteredApp => SelectedProfile?.AppId != null; + private string? _clientLogoPreview; public string? ClientLogoPreview { @@ -47,17 +60,21 @@ public partial class ProfileManagementViewModel : ObservableObject public IAsyncRelayCommand BrowseClientLogoCommand { get; } public IAsyncRelayCommand ClearClientLogoCommand { get; } public IAsyncRelayCommand AutoPullClientLogoCommand { get; } + public IAsyncRelayCommand RegisterAppCommand { get; } + public IAsyncRelayCommand RemoveAppCommand { get; } public ProfileManagementViewModel( ProfileService profileService, IBrandingService brandingService, AppGraphClientFactory graphClientFactory, - ILogger logger) + ILogger logger, + IAppRegistrationService appRegistrationService) { _profileService = profileService; _brandingService = brandingService; _graphClientFactory = graphClientFactory; _logger = logger; + _appRegistrationService = appRegistrationService; AddCommand = new AsyncRelayCommand(AddAsync, CanAdd); RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName)); @@ -65,6 +82,8 @@ public partial class ProfileManagementViewModel : ObservableObject BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null); ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null); AutoPullClientLogoCommand = new AsyncRelayCommand(AutoPullClientLogoAsync, () => SelectedProfile != null); + RegisterAppCommand = new AsyncRelayCommand(RegisterAppAsync, CanRegisterApp); + RemoveAppCommand = new AsyncRelayCommand(RemoveAppAsync, CanRemoveApp); } public async Task LoadAsync() @@ -94,6 +113,15 @@ public partial class ProfileManagementViewModel : ObservableObject AutoPullClientLogoCommand.NotifyCanExecuteChanged(); RenameCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(HasRegisteredApp)); + RegisterAppCommand.NotifyCanExecuteChanged(); + RemoveAppCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsRegisteringChanged(bool value) + { + RegisterAppCommand.NotifyCanExecuteChanged(); + RemoveAppCommand.NotifyCanExecuteChanged(); } private static string? FormatLogoPreview(LogoData? logo) @@ -256,4 +284,77 @@ public partial class ProfileManagementViewModel : ObservableObject _logger.LogWarning(ex, "Auto-pull client logo failed."); } } + + private bool CanRegisterApp() + => SelectedProfile != null && SelectedProfile.AppId == null && !IsRegistering; + + private bool CanRemoveApp() + => SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering; + + private async Task RegisterAppAsync(CancellationToken ct) + { + if (SelectedProfile == null) return; + IsRegistering = true; + ShowFallbackInstructions = false; + RegistrationStatus = TranslationSource.Instance["profile.register.checking"]; + try + { + var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(SelectedProfile.ClientId, ct); + if (!isAdmin) + { + ShowFallbackInstructions = true; + RegistrationStatus = TranslationSource.Instance["profile.register.noperm"]; + return; + } + + RegistrationStatus = TranslationSource.Instance["profile.register.registering"]; + var result = await _appRegistrationService.RegisterAsync(SelectedProfile.ClientId, SelectedProfile.Name, ct); + + if (result.IsSuccess) + { + SelectedProfile.AppId = result.AppId; + await _profileService.UpdateProfileAsync(SelectedProfile); + RegistrationStatus = TranslationSource.Instance["profile.register.success"]; + OnPropertyChanged(nameof(HasRegisteredApp)); + } + else + { + RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"]; + } + } + catch (Exception ex) + { + RegistrationStatus = ex.Message; + _logger.LogError(ex, "Failed to register application."); + } + finally + { + IsRegistering = false; + } + } + + private async Task RemoveAppAsync(CancellationToken ct) + { + if (SelectedProfile == null) return; + IsRegistering = true; + RegistrationStatus = TranslationSource.Instance["profile.remove.removing"]; + try + { + await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.AppId!, ct); + await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl); + SelectedProfile.AppId = null; + await _profileService.UpdateProfileAsync(SelectedProfile); + RegistrationStatus = TranslationSource.Instance["profile.remove.success"]; + OnPropertyChanged(nameof(HasRegisteredApp)); + } + catch (Exception ex) + { + RegistrationStatus = ex.Message; + _logger.LogError(ex, "Failed to remove application."); + } + finally + { + IsRegistering = false; + } + } }