diff --git a/gclo.Engine.Tests/AccountWizardViewModelTests.cs b/gclo.Engine.Tests/AccountWizardViewModelTests.cs index 9ee962d..4071dbd 100644 --- a/gclo.Engine.Tests/AccountWizardViewModelTests.cs +++ b/gclo.Engine.Tests/AccountWizardViewModelTests.cs @@ -390,6 +390,206 @@ public sealed class AccountWizardViewModelTests : IDisposable + // ---------------------------------------------------------------- default token (#102) + + private AccountWizardViewModel NewWizardWithDefault(string? defaultToken, Account? existing = null, string? existingToken = null) + => new(_store, _orgs, _defaults, existing, () => existingToken, defaultToken: () => defaultToken); + + + + [Fact] + public void NewWizard_WithoutADefaultToken_OffersNoChoice_AndUsesAnOwnToken() + { + AccountWizardViewModel wizard = NewWizardWithDefault(defaultToken: null); + + Assert.False(wizard.HasDefaultToken); + Assert.False(wizard.UseDefaultToken); + Assert.True(wizard.IsOwnTokenSelected); + Assert.Equal(1, wizard.TokenChoiceIndex); + } + + + + [Fact] + public void NewWizard_WithADefaultToken_PreselectsIt() + { + AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default"); + + Assert.True(wizard.HasDefaultToken); + Assert.True(wizard.UseDefaultToken); + Assert.False(wizard.IsOwnTokenSelected); + Assert.Equal(0, wizard.TokenChoiceIndex); + Assert.Equal("", wizard.Token); // the default token is never loaded into the box + Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal); + } + + + + [Fact] + public void TokenChoiceIndex_RoundTripsToUseDefaultToken_AndNotifies() + { + AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default"); + var changed = new List(); + wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + wizard.TokenChoiceIndex = 1; + + Assert.False(wizard.UseDefaultToken); + Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.IsOwnTokenSelected), StringComparison.Ordinal)); + Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.TokenChoiceIndex), StringComparison.Ordinal)); + Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.DefaultTokenCaption), StringComparison.Ordinal)); + + wizard.TokenChoiceIndex = 0; + Assert.True(wizard.UseDefaultToken); + } + + + + [Fact] + public async Task NewWizard_UsingTheDefaultToken_ValidatesWithIt_AndSavesWithoutAnOwnEntry() + { + AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default"); + wizard.Name = "Shared"; + string? validated = null; + _orgs.Handler = (token, _) => + { + validated = token; + return Task.FromResult>(["acme"]); + }; + + await AdvanceToStepAsync(wizard, 3); + Assert.Equal("ghp_default", validated); + Assert.Equal("", wizard.TokenError); + wizard.Organization = "acme"; + wizard.TargetRoot = @"C:\repos"; + await AdvanceToStepAsync(wizard, 4); + await wizard.SaveAsync(); + + Account saved = Assert.Single(_store.GetAll()); + Assert.True(saved.UsesDefaultToken); + Assert.Null(_vault.TryRetrieve(saved.Id)); + Assert.Equal(1, _store.CountUsingDefaultToken()); + } + + + + [Fact] + public async Task NewWizard_UsingTheDefaultToken_WhenItWasRemovedMeanwhile_StaysOnStepTwo() + { + string? liveDefault = "ghp_default"; + var wizard = new AccountWizardViewModel(_store, _orgs, _defaults, defaultToken: () => liveDefault); + wizard.Name = "Shared"; + await AdvanceToStepAsync(wizard, 2); + liveDefault = null; // removed in Settings while the wizard is open + + Assert.False(await wizard.TryAdvanceAsync()); + + Assert.Equal(2, wizard.Step); + Assert.Contains("default token is no longer saved", wizard.TokenError); + Assert.Contains("Settings", wizard.TokenError); + } + + + + [Fact] + public async Task NewWizard_ChoosingAnOwnToken_IgnoresTheDefault_AndStoresTheTypedOne() + { + AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default"); + wizard.Name = "Mine"; + wizard.UseDefaultToken = false; + wizard.Token = "ghp_mine"; + string? validated = null; + _orgs.Handler = (token, _) => + { + validated = token; + return Task.FromResult>(["acme"]); + }; + + await AdvanceToStepAsync(wizard, 3); + wizard.Organization = "acme"; + wizard.TargetRoot = @"C:\repos"; + await AdvanceToStepAsync(wizard, 4); + await wizard.SaveAsync(); + + Assert.Equal("ghp_mine", validated); + Account saved = Assert.Single(_store.GetAll()); + Assert.False(saved.UsesDefaultToken); + Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id)); + } + + + + [Fact] + public async Task EditWizard_DefaultTokenAccount_StartsOnTheDefault_AndCanSwitchToAnOwnToken() + { + Account existing = MakeAccount("Shared") with { TokenSource = TokenSource.Default }; + _store.Save(existing, null); + AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing); + Assert.True(wizard.UseDefaultToken); + Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal); // itself excluded + + wizard.UseDefaultToken = false; + await AdvanceToStepAsync(wizard, 2); + Assert.False(await wizard.TryAdvanceAsync()); // no own token typed yet + Assert.Contains("instead of the default one", wizard.TokenError); + + wizard.Token = "ghp_mine"; + await AdvanceToStepAsync(wizard, 4); + await wizard.SaveAsync(); + + Account saved = Assert.Single(_store.GetAll()); + Assert.False(saved.UsesDefaultToken); + Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id)); + } + + + + [Fact] + public async Task EditWizard_OwnTokenAccount_SwitchingToTheDefault_RemovesItsOwnEntry_AndSaysSo() + { + Account existing = MakeAccount("Work"); + _store.Save(existing, "ghp_own"); + _store.Save(MakeAccount("Other") with { TokenSource = TokenSource.Default }, null); + AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing, "ghp_own"); + Assert.False(wizard.UseDefaultToken); + + wizard.UseDefaultToken = true; + + Assert.Contains("1 other account", wizard.DefaultTokenCaption); + Assert.Contains("own token will be removed", wizard.DefaultTokenCaption); + await AdvanceToStepAsync(wizard, 4); + await wizard.SaveAsync(); + Account saved = _store.FindByName("Work")!; + Assert.True(saved.UsesDefaultToken); + Assert.Null(_vault.TryRetrieve(saved.Id)); + } + + + + [Fact] + public void DefaultTokenCaption_CountsOtherAccounts() + { + _store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null); + _store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null); + + Assert.Contains("2 other accounts", NewWizardWithDefault("ghp_default").DefaultTokenCaption); + } + + + + [Fact] + public void SeededWizard_PreselectsTheDefault_OnlyWhenTheSeedTokenIsTheDefaultToken() + { + var onDefault = new AccountWizardSeed("ghp_default", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8); + var other = new AccountWizardSeed("ghp_other", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8); + + Assert.True(new AccountWizardViewModel(_store, _orgs, onDefault, defaultToken: () => "ghp_default").UseDefaultToken); + Assert.False(new AccountWizardViewModel(_store, _orgs, other, defaultToken: () => "ghp_default").UseDefaultToken); + Assert.False(new AccountWizardViewModel(_store, _orgs, onDefault).UseDefaultToken); + } + + + [Fact] public void SeededWizard_NullSeed_Throws() { diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs index a8903a5..57c6dd3 100644 --- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs +++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs @@ -132,6 +132,69 @@ public sealed class SyncAllCoordinatorTests : IDisposable + // ---------------------------------------------------------------- default token (#102) + + /// A default-token account workspace; the default token is seeded only when asked. + private WorkspaceViewModel CreateDefaultTokenWorkspace(string name, bool withDefaultToken) + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = name, + Organization = "org-" + name, + TargetRoot = Path.Combine(_root, name), + TokenSource = TokenSource.Default, + }; + if(withDefaultToken) + { + _vault.Store(AppSettings.DefaultTokenVaultId, "tok-default"); + } + var workspace = new WorkspaceViewModel( + _lister, + new FakeGitClient(), + _orgs, + handler => new SyncProgress(handler), + TimeSpan.FromMilliseconds(1), + new NullActivityLog(), + account, + _vault); + _workspaces.Add(workspace); + return workspace; + } + + + + [Fact] + public async Task RunAsync_DefaultTokenAccount_WithoutADefaultToken_IsSkipped_NotRun() + { + WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: false); + Assert.True(shared.UsesDefaultToken); + Assert.Equal("", shared.Token); + var states = new List(); + _coordinator.AccountStateChanged = (_, state) => states.Add(state); + + SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None); + + Assert.Equal(0, result.Ran); + Assert.Equal(1, result.Skipped); + Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Skipped], states); + Assert.Empty(_lister.Organizations); // never asked GitHub: skipped before loading + } + + + + [Fact] + public async Task RunAsync_DefaultTokenAccount_WithADefaultToken_Runs() + { + WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: true); + Assert.Equal("tok-default", shared.Token); + + SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None); + + Assert.Equal(1, result.Ran); + Assert.Equal(0, result.Skipped); + } + // ---------------------------------------------------------------- ordering [Fact] diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 297cf11..9c6852d 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -1816,6 +1816,7 @@ public sealed class WorkspaceViewModelTests : IDisposable WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault); Assert.Equal("default-token-1234567890", vm.Token); + Assert.True(vm.UsesDefaultToken); await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire"); Assert.Equal("default-token-1234567890", lookedUpToken); } diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs index e214647..69d71b0 100644 --- a/gclo.ViewModels/AccountWizardViewModel.cs +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -36,13 +36,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject /// private readonly Func? _storedToken; + /// + /// Fetches the default token from Settings on demand (null, or returning null, + /// when none is saved). Read when step 2 validates or the account is saved with + /// "Use the default token"; never copied into (#102). + /// + private readonly Func? _defaultToken; + /// /// A wizard for a new account seeded from , or — when /// is given — an edit wizard seeded from that account. /// fetches the vault's current token on demand for - /// an edit (null, or returning null, when the vault has no entry). + /// an edit (null, or returning null, when the vault has no entry); + /// fetches the default token from Settings the + /// same way and, when it returns one, offers "Use the default token" on step 2. /// public AccountWizardViewModel( AccountsStore store, @@ -50,8 +59,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject AppSettings defaults, Account? existing = null, Func? storedToken = null, - IActivityLog? log = null) - : this(store, orgLister, existing, storedToken, log) + IActivityLog? log = null, + Func? defaultToken = null) + : this(store, orgLister, existing, storedToken, defaultToken, log) { ArgumentNullException.ThrowIfNull(defaults); @@ -85,13 +95,19 @@ public sealed partial class AccountWizardViewModel : ObservableObject /// always written to the vault on save. /// public AccountWizardViewModel( - AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null) - : this(store, orgLister, existing: null, storedToken: null, log) + AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null, + Func? defaultToken = null) + : this(store, orgLister, existing: null, storedToken: null, defaultToken, log) { ArgumentNullException.ThrowIfNull(seed); Name = ""; Description = ""; Token = seed.Token; + // A Quick Sync connection that runs on the default token becomes a + // default-token account; any other token is the account's own. Compared in + // memory only; neither value is logged. + UseDefaultToken = HasDefaultToken + && string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal); Organization = seed.Organization; TargetRoot = seed.TargetRoot; CreateOrgSubfolder = seed.CreateOrgSubfolder; @@ -101,7 +117,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject private AccountWizardViewModel( - AccountsStore store, IOrganizationLister orgLister, Account? existing, Func? storedToken, IActivityLog? log) + AccountsStore store, IOrganizationLister orgLister, Account? existing, Func? storedToken, + Func? defaultToken, IActivityLog? log) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(orgLister); @@ -110,6 +127,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject _log = log ?? new NullActivityLog(); _existing = existing; _storedToken = existing is null ? null : storedToken; + _defaultToken = defaultToken; + // Decided once: the choice is offered only when a default token exists at + // open time, and the value itself is fetched again when a step needs it. + HasDefaultToken = !string.IsNullOrWhiteSpace(defaultToken?.Invoke()); + UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken; Step = 1; NameError = ""; @@ -167,6 +189,59 @@ public sealed partial class AccountWizardViewModel : ObservableObject [ObservableProperty] public partial bool IsValidatingToken { get; set; } + /// + /// True when a default token was saved in Settings when the wizard opened, which + /// is when step 2 offers the choice between it and a token of the account's own. + /// + public bool HasDefaultToken { get; } + + /// + /// Step 2's choice: true means the account resolves to the default token from + /// Settings () and the token box is disabled; + /// false means a token of its own. Preselected for a new account when a default + /// token exists, and from the account's current source for an edit. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOwnTokenSelected))] + [NotifyPropertyChangedFor(nameof(TokenChoiceIndex))] + [NotifyPropertyChangedFor(nameof(DefaultTokenCaption))] + public partial bool UseDefaultToken { get; set; } + + /// The token box is enabled only for a token of the account's own. + public bool IsOwnTokenSelected => !UseDefaultToken; + + /// + /// as the selected index of a two-item radio group: + /// 0 = the default token, 1 = a token of this account's own. + /// + public int TokenChoiceIndex + { + get => UseDefaultToken ? 0 : 1; + set => UseDefaultToken = value == 0; + } + + /// + /// What choosing the default token means for this account, with how many other + /// accounts share it; shown under the radio button. + /// + public string DefaultTokenCaption + { + get + { + int others = _store.CountUsingDefaultToken() - (_existing?.UsesDefaultToken == true ? 1 : 0); + string shared = others switch + { + 0 => "Used by Quick Sync.", + 1 => "Used by Quick Sync and 1 other account.", + _ => $"Used by Quick Sync and {others} other accounts.", + }; + string switching = IsEditing && _existing?.UsesDefaultToken == false && UseDefaultToken + ? " This account's own token will be removed from Windows Credential Manager on save." + : ""; + return shared + " Replacing it in Settings updates this account too." + switching; + } + } + /// Step 1's validation message; empty when the name is acceptable. [ObservableProperty] public partial string NameError { get; set; } @@ -254,16 +329,34 @@ public sealed partial class AccountWizardViewModel : ObservableObject // token and returns the organizations the dropdown offers otherwise. // An edit with the box left empty validates the STORED token, fetched // here and used for this one call only. - string candidate = Token.Trim(); - if(candidate.Length == 0 && IsEditing) + string candidate; + if(UseDefaultToken) { - candidate = _storedToken?.Invoke()?.Trim() ?? ""; + // The default token is fetched for this one call only; the box + // stays untouched (and disabled) so it is never shown or retained. + candidate = _defaultToken?.Invoke()?.Trim() ?? ""; if(candidate.Length == 0) { - TokenError = "This account has no stored token. Enter one to continue."; + TokenError = "The default token is no longer saved. Save one in Settings, " + + "or use a different token for this account."; return false; } } + else + { + candidate = Token.Trim(); + if(candidate.Length == 0 && IsEditing) + { + candidate = _storedToken?.Invoke()?.Trim() ?? ""; + if(candidate.Length == 0) + { + TokenError = _existing?.UsesDefaultToken == true + ? "Enter the token this account should use instead of the default one." + : "This account has no stored token. Enter one to continue."; + return false; + } + } + } IsValidatingToken = true; _log.Info("Account wizard: validating the token."); @@ -333,7 +426,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject /// public Task SaveAsync() { - _store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null); + // A default-token account stores no token of its own (the store removes any + // leftover entry when an account switches); otherwise new accounts always + // persist their token and edits only when one was typed. + _store.Save(BuildAccount(), !UseDefaultToken && TokenChanged ? Token.Trim() : null); return Task.CompletedTask; } @@ -353,6 +449,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject TargetRoot = TargetRoot.Trim(), CreateOrgSubfolder = CreateOrgSubfolder, MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency), + TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own, LastSyncUtc = _existing?.LastSyncUtc, LastSyncSummary = _existing?.LastSyncSummary, }; diff --git a/gclo.ViewModels/SyncAllCoordinator.cs b/gclo.ViewModels/SyncAllCoordinator.cs index 6c47481..6808152 100644 --- a/gclo.ViewModels/SyncAllCoordinator.cs +++ b/gclo.ViewModels/SyncAllCoordinator.cs @@ -78,6 +78,16 @@ public sealed class SyncAllCoordinator(IActivityLog log) continue; } + if(workspace.UsesDefaultToken && string.IsNullOrEmpty(workspace.Token)) + { + // The account resolves to the default token and none is saved: a + // generic auth failure would send the user to the wrong place. + skipped++; + Announce(workspace, SyncAllAccountState.Skipped); + _log.Info($"Sync all: skipped '{workspace.DisplayName}' — no default token is saved (Settings)."); + continue; + } + Announce(workspace, SyncAllAccountState.Running); if(!workspace.HasLoadedRepos) diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index 091fe13..994a40a 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -157,6 +157,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// Name shown for this workspace in navigation: the account's name, or "Quick Sync". public string DisplayName => _account?.Name ?? "Quick Sync"; + /// + /// True for an account workspace whose token is the default token from Settings + /// (): the pane tags it, and Sync All names + /// Settings when that token is missing. + /// + public bool UsesDefaultToken => _account?.UsesDefaultToken == true; + public ObservableCollection Repos { get; } = new(); diff --git a/gclo/AccountWizardDialog.xaml b/gclo/AccountWizardDialog.xaml index 451bf34..b4e1015 100644 --- a/gclo/AccountWizardDialog.xaml +++ b/gclo/AccountWizardDialog.xaml @@ -57,12 +57,31 @@ Style="{ThemeResource CaptionTextBlockStyle}" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Step 2 of 4: Access token" /> + + + + + + + /// The pane item's content: the name, plus a "Default token" tag for an account + /// that resolves to the default token from Settings, so the dependency on + /// Settings is visible where the account is used (#102). + /// + private static object AccountItemContent(Account account) + { + if(!account.UsesDefaultToken) + { + return account.Name; + } + var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + panel.Children.Add(new TextBlock { Text = account.Name, VerticalAlignment = VerticalAlignment.Center }); + panel.Children.Add(new TextBlock + { + Text = "Default token", + VerticalAlignment = VerticalAlignment.Center, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + return panel; + } + + + /// The Edit/Delete context menu for an account's pane item. private MenuFlyout CreateAccountContextFlyout(Guid id) { @@ -688,7 +714,8 @@ public sealed partial class MainWindow : Window // validation call; the wizard never displays or retains it. Func? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id); var viewModel = new AccountWizardViewModel( - _accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log); + _accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log, + defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId)); var dialog = new AccountWizardDialog( viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) { @@ -737,7 +764,9 @@ public sealed partial class MainWindow : Window /// private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed) { - var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log); + var viewModel = new AccountWizardViewModel( + _accountsStore, new GitHubOrganizationLister(), seed, _log, + defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId)); var dialog = new AccountWizardDialog( viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) { @@ -798,7 +827,8 @@ public sealed partial class MainWindow : Window if(_navItems.TryGetValue(id, out NavigationViewItem? item)) { - item.Content = account.Name; + item.Content = AccountItemContent(account); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name); ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced"); }