Files
Sharepoint-Toolbox/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
2026-04-15 14:27:31 +02:00

377 lines
14 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;
// Well-known public client (Microsoft Graph Command Line Tools) used as a bootstrap
// when a profile has no ClientId yet, so the user can sign in as admin and have the
// app registration created for them.
private const string BootstrapClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
[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;
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
return true;
}
private async Task AddAsync()
{
if (!CanAdd()) return;
try
{
var profile = new TenantProfile
{
Name = NewName.Trim(),
TenantUrl = NewTenantUrl.Trim(),
ClientId = NewClientId?.Trim() ?? string.Empty
};
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
{
// Use the profile's own ClientId if it has one; otherwise bootstrap with the
// Microsoft Graph Command Line Tools public client so a first-time profile
// (name + URL only) can still perform the admin check and registration.
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
? BootstrapClientId
: SelectedProfile.ClientId;
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(authClientId, ct);
if (!isAdmin)
{
ShowFallbackInstructions = true;
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
return;
}
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct);
if (result.IsSuccess)
{
SelectedProfile.AppId = result.AppId;
// If the profile had no ClientId, adopt the freshly registered app's id
// so subsequent sign-ins use the profile's own app registration.
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
SelectedProfile.ClientId = 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;
}
}
}