chore: release v2.4

- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit 12dd1de9f2
93 changed files with 8708 additions and 1159 deletions
@@ -60,7 +60,7 @@ public partial class ProfileManagementViewModel : ObservableObject
public ObservableCollection<TenantProfile> Profiles { get; } = new();
public IAsyncRelayCommand AddCommand { get; }
public IAsyncRelayCommand RenameCommand { get; }
public IAsyncRelayCommand SaveCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
public IAsyncRelayCommand ClearClientLogoCommand { get; }
@@ -82,7 +82,7 @@ public partial class ProfileManagementViewModel : ObservableObject
_appRegistrationService = appRegistrationService;
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave);
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
@@ -112,11 +112,24 @@ public partial class ProfileManagementViewModel : ObservableObject
partial void OnSelectedProfileChanged(TenantProfile? value)
{
if (value != null)
{
NewName = value.Name;
NewTenantUrl = value.TenantUrl;
NewClientId = value.ClientId ?? string.Empty;
}
else
{
NewName = string.Empty;
NewTenantUrl = string.Empty;
NewClientId = string.Empty;
}
ValidationMessage = string.Empty;
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
BrowseClientLogoCommand.NotifyCanExecuteChanged();
ClearClientLogoCommand.NotifyCanExecuteChanged();
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(HasRegisteredApp));
RegisterAppCommand.NotifyCanExecuteChanged();
@@ -135,17 +148,30 @@ public partial class ProfileManagementViewModel : ObservableObject
private void NotifyCommandsCanExecuteChanged()
{
AddCommand.NotifyCanExecuteChanged();
RenameCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
}
private bool CanAdd()
{
if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
// Fields mirror the selected profile after selection; block Add so the user doesn't
// create a duplicate — they should use Save to update, or change the name to fork.
if (SelectedProfile != null &&
string.Equals(NewName.Trim(), SelectedProfile.Name, StringComparison.Ordinal))
return false;
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
return true;
}
private bool CanSave()
{
if (SelectedProfile == null) return false;
if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
return true;
}
private async Task AddAsync()
{
if (!CanAdd()) return;
@@ -171,19 +197,43 @@ public partial class ProfileManagementViewModel : ObservableObject
}
}
private async Task RenameAsync()
private async Task SaveAsync()
{
if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return;
if (!CanSave()) return;
var target = SelectedProfile!;
try
{
await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim());
SelectedProfile.Name = NewName.Trim();
NewName = string.Empty;
var newName = NewName.Trim();
var newUrl = NewTenantUrl.Trim();
var newClientId = NewClientId?.Trim() ?? string.Empty;
var oldName = target.Name;
if (!string.Equals(oldName, newName, StringComparison.Ordinal))
{
await _profileService.RenameProfileAsync(oldName, newName);
target.Name = newName;
}
target.TenantUrl = newUrl;
target.ClientId = newClientId;
await _profileService.UpdateProfileAsync(target);
// Force ListBox to pick up the renamed entry (TenantProfile is a plain POCO,
// so mutating Name does not raise PropertyChanged).
var idx = Profiles.IndexOf(target);
if (idx >= 0)
{
Profiles.RemoveAt(idx);
Profiles.Insert(idx, target);
SelectedProfile = target;
}
ValidationMessage = string.Empty;
}
catch (Exception ex)
{
ValidationMessage = ex.Message;
_logger.LogError(ex, "Failed to rename profile.");
_logger.LogError(ex, "Failed to save profile.");
}
}
@@ -306,21 +356,17 @@ public partial class ProfileManagementViewModel : ObservableObject
{
// 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.
// (name + URL only) can still perform 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;
}
// No preflight admin check: it used Global Admin as the criterion and
// rejected Application Admins / Cloud Application Admins who can also
// create apps. Let Entra enforce authorization via the POST itself —
// any 401/403 returns FallbackRequired and triggers the tutorial.
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct);
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.TenantUrl, SelectedProfile.Name, ct);
if (result.IsSuccess)
{
@@ -330,9 +376,18 @@ public partial class ProfileManagementViewModel : ObservableObject
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
SelectedProfile.ClientId = result.AppId!;
await _profileService.UpdateProfileAsync(SelectedProfile);
// Reflect adopted ClientId in the bound TextBox. Without this the
// field stays blank and the next Save would overwrite the stored
// ClientId with an empty string.
NewClientId = SelectedProfile.ClientId;
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
OnPropertyChanged(nameof(HasRegisteredApp));
}
else if (result.IsFallback)
{
ShowFallbackInstructions = true;
RegistrationStatus = TranslationSource.Instance["profile.register.noperm"];
}
else
{
RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"];
@@ -356,7 +411,7 @@ public partial class ProfileManagementViewModel : ObservableObject
RegistrationStatus = TranslationSource.Instance["profile.remove.removing"];
try
{
await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.AppId!, ct);
await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl, SelectedProfile.AppId!, ct);
await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl);
SelectedProfile.AppId = null;
await _profileService.UpdateProfileAsync(SelectedProfile);