- 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
361 lines
13 KiB
C#
361 lines
13 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
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;
|
|
|
|
namespace SharepointToolbox.ViewModels;
|
|
|
|
public partial class ProfileManagementViewModel : ObservableObject
|
|
{
|
|
private readonly ProfileService _profileService;
|
|
private readonly IBrandingService _brandingService;
|
|
private readonly AppGraphClientFactory _graphClientFactory;
|
|
private readonly ILogger<ProfileManagementViewModel> _logger;
|
|
private readonly IAppRegistrationService _appRegistrationService;
|
|
|
|
[ObservableProperty]
|
|
private TenantProfile? _selectedProfile;
|
|
|
|
[ObservableProperty]
|
|
private string _newName = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string _newTenantUrl = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string _newClientId = string.Empty;
|
|
|
|
[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
|
|
{
|
|
get => _clientLogoPreview;
|
|
private set { _clientLogoPreview = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
public ObservableCollection<TenantProfile> Profiles { get; } = new();
|
|
|
|
public IAsyncRelayCommand AddCommand { get; }
|
|
public IAsyncRelayCommand RenameCommand { get; }
|
|
public IAsyncRelayCommand DeleteCommand { get; }
|
|
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<ProfileManagementViewModel> 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));
|
|
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
|
|
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()
|
|
{
|
|
try
|
|
{
|
|
var profiles = await _profileService.GetProfilesAsync();
|
|
Profiles.Clear();
|
|
foreach (var p in profiles)
|
|
Profiles.Add(p);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load profiles.");
|
|
}
|
|
}
|
|
|
|
partial void OnNewNameChanged(string value) => NotifyCommandsCanExecuteChanged();
|
|
partial void OnNewTenantUrlChanged(string value) => NotifyCommandsCanExecuteChanged();
|
|
partial void OnNewClientIdChanged(string value) => NotifyCommandsCanExecuteChanged();
|
|
|
|
partial void OnSelectedProfileChanged(TenantProfile? value)
|
|
{
|
|
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
|
|
BrowseClientLogoCommand.NotifyCanExecuteChanged();
|
|
ClearClientLogoCommand.NotifyCanExecuteChanged();
|
|
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)
|
|
=> logo is not null ? $"data:{logo.MimeType};base64,{logo.Base64}" : null;
|
|
|
|
private void NotifyCommandsCanExecuteChanged()
|
|
{
|
|
AddCommand.NotifyCanExecuteChanged();
|
|
RenameCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private bool CanAdd()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
|
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
|
if (string.IsNullOrWhiteSpace(NewClientId)) return false;
|
|
return true;
|
|
}
|
|
|
|
private async Task AddAsync()
|
|
{
|
|
if (!CanAdd()) return;
|
|
try
|
|
{
|
|
var profile = new TenantProfile
|
|
{
|
|
Name = NewName.Trim(),
|
|
TenantUrl = NewTenantUrl.Trim(),
|
|
ClientId = NewClientId.Trim()
|
|
};
|
|
await _profileService.AddProfileAsync(profile);
|
|
Profiles.Add(profile);
|
|
NewName = string.Empty;
|
|
NewTenantUrl = string.Empty;
|
|
NewClientId = string.Empty;
|
|
ValidationMessage = string.Empty;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ValidationMessage = ex.Message;
|
|
_logger.LogError(ex, "Failed to add profile.");
|
|
}
|
|
}
|
|
|
|
private async Task RenameAsync()
|
|
{
|
|
if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return;
|
|
try
|
|
{
|
|
await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim());
|
|
SelectedProfile.Name = NewName.Trim();
|
|
NewName = string.Empty;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ValidationMessage = ex.Message;
|
|
_logger.LogError(ex, "Failed to rename profile.");
|
|
}
|
|
}
|
|
|
|
private async Task DeleteAsync()
|
|
{
|
|
if (SelectedProfile == null) return;
|
|
try
|
|
{
|
|
await _profileService.DeleteProfileAsync(SelectedProfile.Name);
|
|
Profiles.Remove(SelectedProfile);
|
|
SelectedProfile = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ValidationMessage = ex.Message;
|
|
_logger.LogError(ex, "Failed to delete profile.");
|
|
}
|
|
}
|
|
|
|
private async Task BrowseClientLogoAsync()
|
|
{
|
|
if (SelectedProfile == null) return;
|
|
var dialog = new OpenFileDialog
|
|
{
|
|
Title = "Select client logo",
|
|
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
|
|
SelectedProfile.ClientLogo = logo;
|
|
ClientLogoPreview = FormatLogoPreview(logo);
|
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
|
ValidationMessage = string.Empty;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ValidationMessage = ex.Message;
|
|
_logger.LogError(ex, "Failed to import client logo.");
|
|
}
|
|
}
|
|
|
|
private async Task ClearClientLogoAsync()
|
|
{
|
|
if (SelectedProfile == null) return;
|
|
try
|
|
{
|
|
SelectedProfile.ClientLogo = null;
|
|
ClientLogoPreview = null;
|
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
|
ValidationMessage = string.Empty;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ValidationMessage = ex.Message;
|
|
_logger.LogError(ex, "Failed to clear client logo.");
|
|
}
|
|
}
|
|
|
|
private async Task AutoPullClientLogoAsync()
|
|
{
|
|
if (SelectedProfile == null) return;
|
|
try
|
|
{
|
|
var graphClient = await _graphClientFactory.CreateClientAsync(
|
|
SelectedProfile.ClientId, CancellationToken.None);
|
|
|
|
var orgs = await graphClient.Organization.GetAsync();
|
|
var orgId = orgs?.Value?.FirstOrDefault()?.Id;
|
|
if (orgId is null)
|
|
{
|
|
ValidationMessage = "Could not determine organization ID.";
|
|
return;
|
|
}
|
|
|
|
var stream = await graphClient.Organization[orgId]
|
|
.Branding.Localizations["default"].SquareLogo.GetAsync();
|
|
|
|
if (stream is null || stream.Length == 0)
|
|
{
|
|
ValidationMessage = "No branding logo found for this tenant.";
|
|
return;
|
|
}
|
|
|
|
using var ms = new MemoryStream();
|
|
await stream.CopyToAsync(ms);
|
|
var bytes = ms.ToArray();
|
|
|
|
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
|
|
SelectedProfile.ClientLogo = logo;
|
|
ClientLogoPreview = FormatLogoPreview(logo);
|
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
|
ValidationMessage = "Client logo pulled from Entra branding.";
|
|
}
|
|
catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (ex.ResponseStatusCode == 404)
|
|
{
|
|
ValidationMessage = "No Entra branding configured for this tenant.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ValidationMessage = $"Failed to pull logo: {ex.Message}";
|
|
_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;
|
|
}
|
|
}
|
|
}
|