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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user