fix(app): a default token is never left out of sight, and a wizard is given the default token of its own place only

Findings of the review of the default token per forge (#212).

- A Gitea default token could stay in Credential Manager with nothing in
  Settings pointing at it (settings.json not written, or written back by an
  older gclo): still used for that server, but not shown and not removable.
  An address entered in Settings that already has a default token is now
  taken up as the default it is, so it can be seen, replaced and removed.
- Moving the Gitea default to another server stores the new token first and
  removes the old one after: a store that fails leaves the old default whole.
- The wizards for editing an account and for saving a Quick Sync connection
  take the default token per place, as the wizard for a new account does. A
  caller that knows one default token gives GitHub's, and it is offered and
  sent for GitHub only. The rule was in the shell; it is in the view model
  and has tests.
- "Open where you left off" is written whenever the window closes, not only
  when it differs from what was read at the start.
- Messages that tell two servers apart name the whole address, not the host
  (two servers can share a host).
- An address typed over and typed back is the server again without the box
  being left (Load and Find stayed withheld). A token typed behind the mask
  in Settings is stored without the mask's dots.
- A saved token without a server is refused when the Gitea group is made.
- Tests for: servers under a path on one host, a wizard for an account whose
  place cannot be used, a token typed with the address taken out, path
  recovery while the address is no place, GitHub having no address to edit.
  A test on a lookup in flight fails fast instead of waiting for ever.
- README, ADR 0010, threat model T13, comments and test names that still
  said "the default token is GitHub's".

Refs #212

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-11 08:08:23 -06:00
1 parent d88cf08413
commit 1fc76d1000
17 files changed
+452 -57

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ On launch, gclo shows a brief branded splash and then opens where you left off:
**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. What it completes is its own. On Windows, a file that is already where the repository has one is replaced only when it is what the unfinished checkout wrote there (its content, or the beginning of it, line endings aside); for anything else the row fails and names the file: move it and sync again. The same check guards a repository that was empty when it was cloned and gets its first files later. Such a clone follows the origin's default branch when nothing has been put into its folder (otherwise it keeps the branch its HEAD names, and stays empty until origin has that branch); a clone that has branches of its own is left as it is when its HEAD names a branch without commits (a branch you started there yourself). If you have worked in an unfinished clone with git in the meantime, gclo finishes it only when every file there is still the repository's own and nothing you added with `git add` would be removed; otherwise the row fails and names the file. While a canceled clone or update is still ending, syncing that repository again fails with a message to try again when it has finished. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). This applies to a repository gclo could not check out when it cloned it. A repository that was already checked out normally is never rewritten: when an update brings it such a path, it fails and nothing in it is changed, and **Resolve…** (or `--sanitize-paths`) then says how to get a fresh clone with the renames instead of touching it. When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo/<org>/<repo>` inside WSL (reachable from Explorer as `\wsl.localhost\<distro>\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`).
**Settings** (File > Settings…) let you set a default GitHub token (stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**Settings** (File > Settings…) let you set default tokens (one for GitHub, and one for a Gitea server together with its address; stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day, kept for 30 days; they record run parameters, per-repo failures, and when the window stopped responding (for how long, and what was running), and never contain your token (see [SECURITY.md](SECURITY.md)).
+6 -3
View File
@@ -194,9 +194,12 @@ place and is never used for another.**
token of the new server and removes the old one's; a refused address disables
Save. One default Gitea server: more servers are what accounts are for.
- The address is in `settings.json` (`DefaultGiteaServer`); the token is in the
vault. An older gclo that rewrites `settings.json` drops the address: the token
stays in the vault under its entry and is found again once the address is
entered in Settings.
vault. The two can come apart (an older gclo rewrites `settings.json` without
the address; the file cannot be written or read): the token is then still
found by whatever asks for the default of that server, while Settings shows
none. An address entered in Settings that already has a default token in the
vault is therefore taken up as the default it is, so the token can be seen,
replaced and removed again.
## Alternatives considered
+1 -1
View File
@@ -101,7 +101,7 @@ flowchart LR
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; a default token belongs to one place (GitHub, or one Gitea server: its vault entry is named after the place) and is never handed to an account, a wizard or a Quick Sync on another (#212), and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token on offer is the one saved for the place that is chosen, never another's. Quick Sync (#209): no place is chosen for the user, and its token box takes a token only once one is chosen; a token that is typed, pasted or filled in is sent nowhere until the user presses Find or Load repositories, and then to the listers of the place on the card; a server address is taken when its box is left, not while it is typed; choosing another place empties the token, the organization and the table (whose clone addresses are on the other host); what is remembered of a connection is its place, organization and folder, never a token. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another. `settings.json` also names the place Quick Sync opens on: one that is changed there opens the card on another server with the organization filled in, and no token (Quick Sync keeps none, and a default token is found only for the place it was saved for); the title of the card names the server before anything is sent | Accepted (same-user boundary: whoever can write these files can read the tokens) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token, an account's or a server's default one (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another. `settings.json` also names the place Quick Sync opens on: one that is changed there opens the card on another server with the organization filled in, and no token (Quick Sync keeps none, and a default token is found only for the place it was saved for); the title of the card names the server before anything is sent | Accepted (same-user boundary: whoever can write these files can read the tokens) |
## 7. Accepted risks
+2 -2
View File
@@ -314,7 +314,7 @@ public sealed class ForgeCliTests : IDisposable
() => Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
Assert.Equal(
"No default token is saved for git.example.test, so the account could not sync. Save one in the desktop app "
"No default token is saved for https://git.example.test/, so the account could not sync. Save one in the desktop app "
+ "(File > Settings), or give the account a token of its own with --token-env, --token-file, or --token-stdin.",
error.Message);
Assert.Empty(Store.GetAll());
@@ -378,7 +378,7 @@ public sealed class ForgeCliTests : IDisposable
try
{
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
Assert.StartsWith("No default token is saved for git.example.test, so the account could not sync.", error.Message, StringComparison.Ordinal);
Assert.StartsWith("No default token is saved for https://git.example.test/, so the account could not sync.", error.Message, StringComparison.Ordinal);
Assert.Equal(TokenSource.Own, Store.FindByName("forge")!.TokenSource);
Assert.Equal(0, Accounts("edit", "--name", "forge", "--token-env", "GCLO_FORGE_TEST_TOKEN"));
+1 -1
View File
@@ -546,7 +546,7 @@ internal static class AccountsCommand
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultTokenFor(place))
{
throw new CliErrorException(
$"No default token is saved for {place}, so the account could not sync. Save one in the desktop app "
$"No default token is saved for {place.Server?.AbsoluteUri ?? place.DisplayName}, so the account could not sync. Save one in the desktop app "
+ "(File > Settings), or give the account a token of its own with --token-env, "
+ "--token-file, or --token-stdin.");
}
+204 -5
View File
@@ -72,6 +72,11 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
// Pinned: tokens that are saved are found again by this value.
Assert.Equal(new Guid("18734666-9532-7707-54df-1f8d61342bc1"), DefaultTokens.EntryFor(s_gitea));
Assert.NotEqual(DefaultTokens.EntryFor(s_gitea), DefaultTokens.EntryFor(s_other));
// Two servers on one host are two places: each has an entry of its own.
ForgeEndpoint underAPath = Place("https://git.example.test/gitea/");
Assert.NotEqual(DefaultTokens.EntryFor(s_gitea), DefaultTokens.EntryFor(underAPath));
Assert.NotEqual(DefaultTokens.EntryFor(underAPath), DefaultTokens.EntryFor(Place("https://git.example.test/mirror/")));
Assert.Throws<ArgumentNullException>(() => DefaultTokens.EntryFor(null!));
}
@@ -191,7 +196,7 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
SettingsGiteaPlan store = state.Plan("git.example.test", "gitea_typed");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), store);
Assert.Equal(
"The default token for git.example.test will be saved on Save; Quick Sync fills it in when it is on that server.",
"The default token for https://git.example.test/ will be saved on Save; Quick Sync fills it in when it is on that server.",
state.Caption("git.example.test", "gitea_typed"));
var settings = new AppSettings();
@@ -220,7 +225,7 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
// Untouched: kept, and it says who uses it.
Assert.Equal(SettingsTokenAction.Keep, state.Plan(Server, SettingsTokenState.Mask).Action);
Assert.Equal(
"A default token is saved for git.example.test and used by Quick Sync and 2 accounts. Type to replace it; clear the box to remove it.",
"A default token is saved for https://git.example.test/ and used by Quick Sync and 2 accounts. Type to replace it; clear the box to remove it.",
state.Caption(Server, SettingsTokenState.Mask));
Assert.False(state.IsWarning(Server, SettingsTokenState.Mask));
Assert.Null(state.Apply(state.Plan(Server, SettingsTokenState.Mask), SettingsTokenState.Mask, _vault, settings));
@@ -239,6 +244,13 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
Assert.Equal("default token for git.example.test replaced", state.Apply(replace, "gitea_new", _vault, settings));
Assert.Equal("gitea_new", DefaultTokens.For(_vault, s_gitea));
// A token typed with the address taken out has no server to be for: nothing is stored, nothing removed.
SettingsGiteaPlan noAddress = state.Plan("", "gitea_for_nowhere");
Assert.True(noAddress.IsError);
Assert.Equal("Enter the address of the server this token is for.", noAddress.Error);
Assert.Throws<InvalidOperationException>(() => state.Apply(noAddress, "gitea_for_nowhere", _vault, settings));
Assert.Equal("gitea_new", DefaultTokens.For(_vault, s_gitea));
// The box cleared, or the address cleared: removed, with the accounts it breaks named.
foreach((string server, string box) in ((string, string)[])[(Server, ""), ("", SettingsTokenState.Mask), ("", "")])
{
@@ -271,7 +283,7 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
SettingsGiteaPlan refused = state.Plan(OtherServer, SettingsTokenState.Mask);
Assert.True(refused.IsError);
Assert.Equal(
"The saved token is for git.example.test. Enter the token for other.example.test, or put the address back.",
"The saved token is for https://git.example.test/. Enter the token for https://other.example.test/, or put the address back.",
refused.Error);
Assert.True(state.IsWarning(OtherServer, SettingsTokenState.Mask));
Assert.Throws<InvalidOperationException>(() => state.Apply(refused, SettingsTokenState.Mask, _vault, settings));
@@ -282,7 +294,7 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
SettingsGiteaPlan move = state.Plan(OtherServer, "other_typed");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_other, ""), move);
Assert.Equal(
"The default token for other.example.test will be saved on Save, and the one for git.example.test removed."
"The default token for https://other.example.test/ will be saved on Save, and the one for https://git.example.test/ removed."
+ " 1 account uses it and will stop syncing until a new one is saved: Forge.",
state.Caption(OtherServer, "other_typed"));
Assert.True(state.IsWarning(OtherServer, "other_typed"));
@@ -295,6 +307,87 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
[Fact]
public void TwoServersOnOneHost_AreToldApartInWhatTheGroupSays()
{
ForgeEndpoint first = Place("https://git.example.test/first/");
var state = new SettingsGiteaState(first, hasSavedToken: true, dependentAccounts: []);
Assert.Equal(
"The saved token is for https://git.example.test/first/. Enter the token for https://git.example.test/second/, or put the address back.",
state.Plan("https://git.example.test/second", SettingsTokenState.Mask).Error);
Assert.Equal(
"The default token for https://git.example.test/second/ will be saved on Save, and the one for https://git.example.test/first/ removed.",
state.Caption("https://git.example.test/second", "typed"));
}
[Fact]
public void ATokenThatCannotBeStoredForAnotherServer_LeavesTheSavedOneWhereItWas()
{
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings { DefaultGiteaServer = Server };
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: ["Forge"]);
var vault = new VaultThatCannotStore(_vault);
Assert.Throws<IOException>(() => state.Apply(state.Plan(OtherServer, "other_typed"), "other_typed", vault, settings));
// The new token was to be stored first: the server that had the default still has it, and is still the default.
Assert.Equal(0, vault.Deleted);
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea));
Assert.Equal(Server, settings.DefaultGiteaServer);
Assert.Equal("the entry of the test", vault.DescribeEntry(Guid.Empty));
}
[Fact]
public void ATokenTheVaultStillHolds_ForAServerSettingsLostSightOf_IsTakenUpAgain_WhenItsAddressIsEntered()
{
// settings.json no longer names the server (an older gclo wrote the file back); its token is still in the vault,
// and is found by whatever asks for the default of that server.
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
var settings = new AppSettings();
var state = new SettingsGiteaState(
savedPlace: null, hasSavedToken: false, dependentAccounts: [], holdsATokenFor: place => DefaultTokens.For(_vault, place) is not null);
Assert.False(state.HasSavedToken);
Assert.Equal("", state.InitialServer);
Assert.Equal("No default token saved for a Gitea server.", state.Caption("", ""));
// The address of a server that has none: nothing to do.
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, null, ""), state.Plan(OtherServer, ""));
Assert.Null(state.Apply(state.Plan(OtherServer, ""), "", _vault, settings));
Assert.Null(settings.DefaultGiteaServer);
// The address of the server whose token is there, and nothing typed: it is the default it was.
SettingsGiteaPlan takeUp = state.Plan("git.example.test", "");
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, s_gitea, "", TakesUp: true), takeUp);
Assert.False(takeUp.IsError);
Assert.Equal(
"A default token is already saved for https://git.example.test/. Save makes this server the Gitea default again; "
+ "open Settings afterwards to replace or remove the token.",
state.Caption("git.example.test", ""));
Assert.False(state.IsWarning("git.example.test", ""));
Assert.Equal(
"default token for git.example.test taken up again (it was already saved)",
state.Apply(takeUp, "", _vault, settings));
Assert.Equal(Server, settings.DefaultGiteaServer);
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea)); // the token itself is not touched
// The dialog that opens next sees it as saved, and can replace or remove it.
var next = new SettingsGiteaState(settings.DefaultGiteaPlace(), hasSavedToken: true, dependentAccounts: []);
Assert.Equal(Server, next.InitialServer);
Assert.Equal(SettingsTokenAction.Remove, next.Plan(Server, "").Action);
// With a token typed for that address: an ordinary save, which replaces what was there.
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), state.Plan("git.example.test", "gitea_new"));
}
[Fact]
public void AGiteaTokenRemoved_ThatNoAccountUses_IsRemovedWithoutAWarning()
{
@@ -334,6 +427,9 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
Assert.Throws<ArgumentNullException>(() => state.Apply(nothing, null!, _vault, new AppSettings()));
Assert.Throws<ArgumentNullException>(() => state.Apply(nothing, "", null!, new AppSettings()));
Assert.Throws<ArgumentNullException>(() => state.Apply(nothing, "", _vault, null!));
// A token that is saved is saved for a server.
Assert.Throws<ArgumentException>(() => new SettingsGiteaState(savedPlace: null, hasSavedToken: true, dependentAccounts: []));
}
@@ -423,15 +519,90 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
store.Save(AccountOn(ForgeEndpoint.GitHub, "Work"), token: null); // on GitHub's default: not counted here
var wizard = new AccountWizardViewModel(
store, new FakeOrganizationLister(), new AppSettings(), forge, () => null, defaultToken: () => "gitea_default");
store, new FakeOrganizationLister(), new AppSettings(), forge, () => null,
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea_default" : "ghp_default");
Assert.True(wizard.UseDefaultToken);
Assert.True(wizard.HasDefaultToken);
Assert.True(wizard.OffersTokenChoice);
Assert.Equal("Used by Quick Sync and 1 other account. Replacing it in Settings updates this account too.", wizard.DefaultTokenCaption);
}
[Fact]
public async Task AWizardForAnAccountOnAGiteaServer_ThatIsHandedGitHubsDefaultToken_OffersAndSendsNone()
{
// The older way to give a wizard "the default token" knows one token, GitHub's. It is no token of this server.
var sent = new List<string>();
var lister = new FakeOrganizationLister
{
Handler = (token, _) =>
{
sent.Add(token);
return Task.FromResult<IReadOnlyList<string>>(["platform"]);
},
};
AccountsStore store = NewStore();
Account own = AccountOn(s_gitea, "Own", TokenSource.Own);
store.Save(own, "gitea_own");
var editing = new AccountWizardViewModel(
store, lister, new AppSettings(), own, () => "gitea_own", defaultToken: () => "ghp_default");
Assert.False(editing.HasDefaultToken);
Assert.False(editing.OffersTokenChoice);
Assert.False(editing.UseDefaultToken);
// Set all the same (the choice is not shown): there is no default token for this server to send.
editing.TokenChoiceIndex = 0;
Assert.True(await editing.TryAdvanceAsync());
Assert.False(await editing.TryAdvanceAsync());
Assert.Equal(
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
editing.TokenError);
Assert.Empty(sent);
// A connection on that server, saved as an account, with the very token GitHub's default is: it is the
// account's own token here, not "the default".
var seed = new AccountWizardSeed("ghp_default", "platform", @"C:\mirror", CreateOrgSubfolder: true, MaxConcurrency: 4, s_gitea);
var saving = new AccountWizardViewModel(store, lister, seed, defaultToken: () => "ghp_default");
Assert.False(saving.HasDefaultToken);
Assert.False(saving.UseDefaultToken);
// The same source on GitHub is GitHub's default token, as it always was.
var onGitHub = new AccountWizardViewModel(store, lister, seed with { Place = null }, defaultToken: () => "ghp_default");
Assert.True(onGitHub.HasDefaultToken);
Assert.True(onGitHub.UseDefaultToken);
}
[Fact]
public void AWizardForAnAccountWhosePlaceCannotBeUsed_HasNoDefaultToken()
{
AccountsStore store = NewStore();
Account later = AccountOn(s_gitea, "Later", TokenSource.Own) with { ForgeName = "GitLab" };
var asked = new List<ForgeEndpoint>();
var wizard = new AccountWizardViewModel(
store, new FakeOrganizationLister(), new AppSettings(), later, () => null,
defaultTokenOf: place =>
{
asked.Add(place);
return "a_default";
});
// It is on no place this gclo knows: there is no place to ask the default token of, and none is asked for.
Assert.False(wizard.HasDefaultToken);
Assert.False(wizard.OffersTokenChoice);
Assert.False(wizard.UseDefaultToken);
Assert.Empty(asked);
}
// ---------------------------------------------------------------- Quick Sync
[Fact]
@@ -459,4 +630,32 @@ public sealed class DefaultTokenPerPlaceTests : IDisposable
Assert.Equal("", workspace.Token);
Assert.Throws<ArgumentNullException>(() => workspace.OnDefaultTokenChanged(null!, null, null));
}
/// <summary>A vault that reads and removes as the one it wraps, and cannot store.</summary>
private sealed class VaultThatCannotStore(InMemoryVault inner) : ITokenVault
{
public int Deleted { get; private set; }
public void Store(Guid accountId, string token) => throw new IOException("the credential store is not available");
public string? TryRetrieve(Guid accountId) => inner.TryRetrieve(accountId);
public void Delete(Guid accountId)
{
Deleted++;
inner.Delete(accountId);
}
public string DescribeEntry(Guid accountId) => "the entry of the test";
}
}
+64 -4
View File
@@ -561,7 +561,7 @@ public sealed class QuickSyncPlaceTests : IDisposable
Task asking = workspace.FindOrganizationsCommand.ExecuteAsync(null);
workspace.ServerIsBeingEdited();
await asking;
await asking.WaitAsync(TimeSpan.FromSeconds(10)); // a lookup that is not ended fails here, and does not wait for ever
Assert.True(answer.Task.IsCanceled);
Assert.Empty(workspace.Organizations);
@@ -570,6 +570,65 @@ public sealed class QuickSyncPlaceTests : IDisposable
[Fact]
public async Task ARowWhosePathsAreToBeResolved_IsNotResolved_WhileTheAddressIsNoPlace()
{
WorkspaceViewModel workspace = QuickSync();
workspace.IsOnGitea = true;
workspace.Server = Server;
await TypeTokenAsync(workspace, "gitea-token-0123456789");
workspace.Organization = "org-at-git.example.test";
workspace.TargetFolder = _root;
await workspace.LoadReposCommand.ExecuteAsync(null);
_git.CloneHandler = (_, path, _, _, _) => string.Equals(Path.GetFileName(path), "alpha", StringComparison.Ordinal)
? Task.FromException(new InvalidRepositoryPathsException([new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_")]))
: Task.CompletedTask;
await workspace.SyncCommand.ExecuteAsync(null);
RepoItemViewModel alpha = Assert.Single(workspace.Repos, row => row.HasPathIssue);
int asked = 0;
workspace.RecoveryInteraction = _ =>
{
asked++;
return Task.FromResult<PathRecoveryDecision?>(null);
};
Assert.True(workspace.ResolvePathsCommand.CanExecute(alpha));
// The address on the card is being typed over: the row is of a server the card no longer names.
workspace.ServerIsBeingEdited();
Assert.False(workspace.ResolvePathsCommand.CanExecute(alpha));
await workspace.ResolvePathsCommand.ExecuteAsync(alpha);
Assert.Equal(0, asked);
// The same address after all: the row can be resolved again.
workspace.Server = Server;
Assert.True(workspace.ResolvePathsCommand.CanExecute(alpha));
await workspace.ResolvePathsCommand.ExecuteAsync(alpha);
Assert.Equal(1, asked);
}
[Fact]
public async Task OnGitHub_ThereIsNoAddressToBeEdited_AndSayingSoChangesNothing()
{
WorkspaceViewModel workspace = QuickSyncOnGitHub();
await TypeTokenAsync(workspace, "ghp_token_0123456789");
workspace.Organization = "org-at-GitHub";
workspace.TargetFolder = @"C:\mirror";
Assert.True(workspace.LoadReposCommand.CanExecute(null));
workspace.ServerIsBeingEdited();
Assert.True(workspace.LoadReposCommand.CanExecute(null));
Assert.True(workspace.FindOrganizationsCommand.CanExecute(null));
Assert.NotNull(workspace.ToAccountSeed());
Assert.Equal("Connect to GitHub", workspace.ConnectTitle);
}
[Fact]
public async Task TheOfferToSaveTheConnection_IsMadeOnce_AndRememberingItFailingDoesNotFailTheLoad()
{
@@ -756,7 +815,7 @@ public sealed class QuickSyncPlaceTests : IDisposable
[Fact]
public void AChangeOfTheDefaultTokenInSettings_ReachesQuickSync_OnlyWhileItIsOnGitHub()
public void AChangeOfGitHubsDefaultToken_ReachesQuickSync_OnlyWhileItIsOnGitHub()
{
WorkspaceViewModel workspace = QuickSync();
@@ -878,7 +937,8 @@ public sealed class QuickSyncPlaceTests : IDisposable
// Had the connection run on the default token of its server, the account would use that one.
Assert.True(new AccountWizardViewModel(
store, ListersOf(s_gitea).Organizations, seed, defaultToken: () => "gitea-token-0123456789").UseDefaultToken);
store, ListersOf(s_gitea).Organizations, seed,
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea-token-0123456789" : null).UseDefaultToken);
wizard.Name = "Platform";
while(wizard.Step < 4)
@@ -1045,7 +1105,7 @@ public sealed class QuickSyncPlaceTests : IDisposable
// ---------------------------------------------------------------- the default token saved for a place
[Fact]
public void TheDefaultTokenInSettings_IsGitHubs_AndNoneForAnotherPlace()
public void GitHubsDefaultToken_IsNoneForAnotherPlace()
{
var vault = new InMemoryVault();
@@ -139,4 +139,24 @@ public sealed class SettingsTokenStateTests
Assert.DoesNotContain(SettingsTokenState.Mask, caption);
}
}
[Fact]
public void WhatWasTypedAfterTheMask_IsTheTokenWithoutTheMask()
{
// Untouched, or typed into an empty box: as it is.
Assert.Equal(SettingsTokenState.Mask, SettingsTokenState.WithoutTheMask(SettingsTokenState.Mask));
Assert.Equal("", SettingsTokenState.WithoutTheMask(""));
Assert.Equal("ghp_typed", SettingsTokenState.WithoutTheMask("ghp_typed"));
// Typed behind, before or into the mask without emptying the box first: the typing alone.
Assert.Equal("ghp_typed", SettingsTokenState.WithoutTheMask(SettingsTokenState.Mask + "ghp_typed"));
Assert.Equal("ghp_typed", SettingsTokenState.WithoutTheMask("ghp_" + SettingsTokenState.Mask + "typed"));
// A part of the mask deleted: nothing was typed, and the rest of the mask is no token.
Assert.Equal("", SettingsTokenState.WithoutTheMask(SettingsTokenState.Mask[1..]));
Assert.Throws<ArgumentNullException>(() => SettingsTokenState.WithoutTheMask(null!));
}
}
+13 -1
View File
@@ -118,7 +118,7 @@ public sealed partial class SmokeTests : UiTestBase
Session.EnterText(Session.WaitForElement("SettingsGiteaServerBox"), "git.example.test");
Session.WaitForText("The default token for git.example.test will be saved on Save; Quick Sync fills it in when it is on that server.");
Session.WaitForText("The default token for https://git.example.test/ will be saved on Save; Quick Sync fills it in when it is on that server.");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button).And(cf.ByName("Save")).And(cf.ByClassName("Button")))
is { IsEnabled: true } save ? save : null,
@@ -367,6 +367,18 @@ public sealed partial class SmokeTests : UiTestBase
},
"the fixture's Gitea organization in the organization list");
orgBox.Collapse();
// The address typed over names no place, so Find is withheld; typed back, without
// the box being left, it is the server again and Find is offered.
AutomationElement serverBox = Session.WaitForElement("ConnectServerBox");
Session.EnterText(serverBox, "https://git.example.test/x");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: false } button ? button : null,
"Find to be withheld while the address is typed over");
Session.EnterText(serverBox, "https://git.example.test/");
Session.WaitFor(
() => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: true } button ? button : null,
"Find to be offered again for the address that was typed back");
Assert.False(Session.App.HasExited);
}
finally
+18 -12
View File
@@ -72,8 +72,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <paramref name="existing"/> is given — an edit wizard seeded from that account.
/// <paramref name="storedToken"/> fetches the vault's current token on demand for
/// an edit (null, or returning null, when the vault has no entry);
/// <paramref name="defaultToken"/> fetches the default token from Settings the
/// same way and, when it returns one, offers "Use the default token" on step 2.
/// <paramref name="defaultToken"/> fetches GitHub's default token from Settings
/// the same way and, when it returns one, offers "Use the default token" on step
/// 2 of a GitHub account; <paramref name="defaultTokenOf"/> fetches the default
/// token of a place, and is what a caller that knows of other places gives. The
/// wizard only ever asks for the default token of the place its account is on.
/// </summary>
public AccountWizardViewModel(
AccountsStore store,
@@ -82,8 +85,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
Account? existing = null,
Func<string?>? storedToken = null,
IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, listerOf: null, existing, storedToken, OfTheOnePlace(defaultToken), log)
Func<string?>? defaultToken = null,
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
: this(store, orgLister, listerOf: null, existing, storedToken, defaultTokenOf ?? OfGitHubOnly(defaultToken), log)
{
ArgumentNullException.ThrowIfNull(defaults);
@@ -139,8 +143,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// </summary>
public AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, listerOf: null, existing: null, storedToken: null, OfTheOnePlace(defaultToken), log)
Func<string?>? defaultToken = null,
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
: this(store, orgLister, listerOf: null, existing: null, storedToken: null, defaultTokenOf ?? OfGitHubOnly(defaultToken), log)
{
ArgumentNullException.ThrowIfNull(seed);
Name = "";
@@ -173,12 +178,13 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// The default token of a wizard that is on one place for good (an edit, a saved
/// Quick Sync connection): what the caller gives is the default token of that
/// place.
/// A source that knows one default token only knows GitHub's, which is what
/// "the default token" was before a server could have one: it is given for
/// GitHub and for no other place. A wizard for an account on a Gitea server is
/// given a source per place (<c>defaultTokenOf</c>) instead.
/// </summary>
private static Func<ForgeEndpoint, string?>? OfTheOnePlace(Func<string?>? defaultToken)
=> defaultToken is null ? null : _ => defaultToken();
private static Func<ForgeEndpoint, string?>? OfGitHubOnly(Func<string?>? defaultToken)
=> defaultToken is null ? null : place => place.Server is null ? defaultToken() : null;
@@ -243,7 +249,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// Whether step 1 asks where the account lives. Only for a new account in a
/// wizard that was given a lister per place; an account being edited stays where
/// it is, and a Quick Sync connection saved as an account is a GitHub connection.
/// it is, and a Quick Sync connection saved as an account is on the place of that connection.
/// </summary>
public bool CanChoosePlace => _listerOf is not null;
+6 -1
View File
@@ -17,7 +17,12 @@ namespace gclo.ViewModels;
/// <param name="Action">What happens to the default token.</param>
/// <param name="Place">The server the action is about; null when nothing is stored or removed.</param>
/// <param name="Error">Why the group cannot be saved as it stands; empty when it can.</param>
public sealed record SettingsGiteaPlan(SettingsTokenAction Action, ForgeEndpoint? Place, string Error)
/// <param name="TakesUp">
/// True when the vault already holds a default token for <paramref name="Place"/>
/// that Settings had lost sight of: Save makes that server the Gitea default again,
/// without a token being typed or stored.
/// </param>
public sealed record SettingsGiteaPlan(SettingsTokenAction Action, ForgeEndpoint? Place, string Error, bool TakesUp = false)
{
/// <summary>True when the dialog must not be saved: the server address or the token needs correcting first.</summary>
public bool IsError => Error.Length > 0;
+54 -11
View File
@@ -17,8 +17,19 @@ namespace gclo.ViewModels;
/// are saved, replaced and removed together.
/// </summary>
/// <remarks>
/// <para>
/// The saved token is never carried to another address. Changing the address
/// asks for the token of the new server; the token of the old one is removed.
/// </para>
/// <para>
/// Which server has the default is written in settings.json, the token is in the
/// vault under an entry named after that server. The two can come apart: the
/// settings file could not be written, or read, or an older gclo wrote it back
/// without the address. The token is then still found by everything that asks for
/// the default of that server, while this dialog shows none. So that it can be
/// seen and removed again, an address that is entered here and already has a
/// default token in the vault is taken up as the default it is.
/// </para>
/// </remarks>
public sealed class SettingsGiteaState
{
@@ -26,18 +37,29 @@ public sealed class SettingsGiteaState
private readonly SettingsTokenState _token;
private readonly Func<ForgeEndpoint, bool> _holdsATokenFor;
/// <summary>
/// <paramref name="savedPlace"/>: the server a default token is saved for, or
/// null; <paramref name="hasSavedToken"/>: whether the vault holds that token;
/// <paramref name="dependentAccounts"/>: the names of the accounts on that
/// server that use the default token, which a removal would break.
/// server that use the default token, which a removal would break;
/// <paramref name="holdsATokenFor"/>: whether the vault holds a default token for
/// a server (see the remarks), none when it is not given.
/// </summary>
public SettingsGiteaState(ForgeEndpoint? savedPlace, bool hasSavedToken, IReadOnlyList<string> dependentAccounts)
public SettingsGiteaState(
ForgeEndpoint? savedPlace, bool hasSavedToken, IReadOnlyList<string> dependentAccounts, Func<ForgeEndpoint, bool>? holdsATokenFor = null)
{
if(hasSavedToken && savedPlace is null)
{
throw new ArgumentException("A token that is saved is saved for a server.", nameof(savedPlace));
}
_savedPlace = savedPlace;
_token = new SettingsTokenState(hasSavedToken && savedPlace is not null, dependentAccounts);
_token = new SettingsTokenState(hasSavedToken, dependentAccounts);
_holdsATokenFor = holdsATokenFor ?? (_ => false);
}
@@ -81,7 +103,13 @@ public sealed class SettingsGiteaState
if(!HasSavedToken)
{
return typed ? new SettingsGiteaPlan(SettingsTokenAction.Store, place, "") : Nothing;
if(typed)
{
return new SettingsGiteaPlan(SettingsTokenAction.Store, place, "");
}
// Nothing typed: nothing to do, unless the vault holds a default token for this very server already.
return _holdsATokenFor(place) ? new SettingsGiteaPlan(SettingsTokenAction.Keep, place, "", TakesUp: true) : Nothing;
}
if(place.Equals(_savedPlace))
@@ -96,11 +124,19 @@ public sealed class SettingsGiteaState
///////////////////////////////////////////////////////////////////////////////
return typed
? new SettingsGiteaPlan(SettingsTokenAction.Store, place, "")
: Refused($"The saved token is for {_savedPlace!.DisplayName}. Enter the token for {place.DisplayName}, or put the address back.");
: Refused($"The saved token is for {Address(_savedPlace!)}. Enter the token for {Address(place)}, or put the address back.");
}
/// <summary>
/// A server as these captions name it: by its whole address, not its host
/// alone. Two servers can share a host, and the captions tell one from the other.
/// </summary>
private static string Address(ForgeEndpoint place) => place.Server?.AbsoluteUri ?? place.DisplayName;
/// <summary>The caption under the group for the boxes as they stand; names what Save will do.</summary>
public string Caption(string server, string boxValue)
{
@@ -113,17 +149,20 @@ public sealed class SettingsGiteaState
switch(plan.Action)
{
case SettingsTokenAction.Store when !HasSavedToken:
return $"The default token for {plan.Place!.DisplayName} will be saved on Save; Quick Sync fills it in when it is on that server.";
return $"The default token for {Address(plan.Place!)} will be saved on Save; Quick Sync fills it in when it is on that server.";
case SettingsTokenAction.Store when plan.Place!.Equals(_savedPlace):
return "The default token will be replaced on Save." + _token.RotationNote();
case SettingsTokenAction.Store:
return $"The default token for {plan.Place!.DisplayName} will be saved on Save, and the one for {_savedPlace!.DisplayName} removed."
return $"The default token for {Address(plan.Place!)} will be saved on Save, and the one for {Address(_savedPlace!)} removed."
+ _token.RemovalWarning();
case SettingsTokenAction.Remove:
return "The default token will be removed on Save." + _token.RemovalWarning();
case SettingsTokenAction.Keep when plan.TakesUp:
return $"A default token is already saved for {Address(plan.Place!)}. Save makes this server the Gitea default again; "
+ "open Settings afterwards to replace or remove the token.";
default:
return HasSavedToken
? $"A default token is saved for {_savedPlace!.DisplayName}" + _token.UsedBy() + ". Type to replace it; clear the box to remove it."
? $"A default token is saved for {Address(_savedPlace!)}" + _token.UsedBy() + ". Type to replace it; clear the box to remove it."
: "No default token saved for a Gitea server.";
}
}
@@ -165,12 +204,13 @@ public sealed class SettingsGiteaState
switch(plan.Action)
{
case SettingsTokenAction.Store:
if(HasSavedToken && !plan.Place!.Equals(_savedPlace))
// The new one first: if it cannot be stored, the old server keeps its token and stays the default.
vault.Store(DefaultTokens.EntryFor(plan.Place!), boxValue);
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
if(HasSavedToken && !plan.Place.Equals(_savedPlace))
{
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
}
vault.Store(DefaultTokens.EntryFor(plan.Place!), boxValue);
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
return HasSavedToken && plan.Place.Equals(_savedPlace)
? $"default token for {plan.Place.DisplayName} replaced"
: $"default token for {plan.Place.DisplayName} stored";
@@ -180,6 +220,9 @@ public sealed class SettingsGiteaState
return _token.DependentCount == 0
? $"default token for {_savedPlace!.DisplayName} removed"
: $"default token for {_savedPlace!.DisplayName} removed ({_token.DependentCount} account(s) depend on it)";
case SettingsTokenAction.Keep when plan.TakesUp:
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
return $"default token for {plan.Place.DisplayName} taken up again (it was already saved)";
default:
return null;
}
+16
View File
@@ -36,6 +36,22 @@ public sealed class SettingsTokenState
/// <summary>
/// What was typed, without the mask. A token box that shows the mask and is
/// typed into without being emptied first holds the mask and then the typing;
/// stored like that, the token would begin with the mask's dots. The mask alone
/// stays the mask (it means "unchanged"); a box that holds none of it is as it is.
/// </summary>
public static string WithoutTheMask(string boxValue)
{
ArgumentNullException.ThrowIfNull(boxValue);
return string.Equals(boxValue, Mask, StringComparison.Ordinal) || !boxValue.Contains(Mask[0], StringComparison.Ordinal)
? boxValue
: boxValue.Replace(Mask[0].ToString(), "", StringComparison.Ordinal);
}
/// <summary>True when the vault held a default token when the dialog opened.</summary>
public bool HasSavedToken { get; }
+5 -5
View File
@@ -7,11 +7,11 @@ namespace gclo.ViewModels;
/// <summary>
/// Where an account's GitHub token comes from. <see cref="Own"/> is the account's
/// own vault entry (keyed by its id); <see cref="Default"/> is the default token
/// saved in Settings (<see cref="AppSettings.DefaultTokenVaultId"/>), read at use
/// time, so replacing the default token in Settings rotates every such account
/// at once (docs/plans/default-token-ux.md).
/// Where an account's token comes from. <see cref="Own"/> is the account's own
/// vault entry (<see cref="Account.TokenEntryId"/>); <see cref="Default"/> is the
/// default token saved in Settings for the place the account is on
/// (<see cref="DefaultTokens"/>), read at use time, so replacing that default token
/// in Settings rotates every such account at once (docs/plans/default-token-ux.md).
/// </summary>
public enum TokenSource
{
+11 -1
View File
@@ -309,10 +309,20 @@ public sealed partial class ConnectionFormControl : UserControl
/// </summary>
private void ServerBox_TextChanged(object sender, TextChangedEventArgs e)
{
if(ViewModel is not null && !string.Equals(ServerBox.Text, ViewModel.Server, StringComparison.Ordinal))
if(ViewModel is null)
{
return;
}
if(!string.Equals(ServerBox.Text, ViewModel.Server, StringComparison.Ordinal))
{
ViewModel.ServerIsBeingEdited();
}
else if(ViewModel.AsksForServer)
{
// The text is the address that was taken again (typed back): it is the place once more, without leaving the box.
ViewModel.Server = ServerBox.Text;
}
}
+4 -6
View File
@@ -205,10 +205,8 @@ public sealed partial class MainWindow : Window
Closed += (_, _) =>
{
if(_settings.LastWorkspace != workspaceAtStart)
{
_settings.SaveWhatWasRemembered(_log);
}
// The workspace that is open now is where the next start opens: written here, whatever was written before.
_settings.SaveWhatWasRemembered(_log);
responsiveness.Dispose();
_logWindow?.Close(); // a log-only process would linger otherwise
DisposeWorkspaces();
@@ -833,7 +831,7 @@ public sealed partial class MainWindow : Window
existing,
() => _tokenVault.TryRetrieve(existing.TokenEntryId),
_log,
() => PlaceOf(existing) is { } place ? DefaultTokens.For(_tokenVault, place) : null);
defaultTokenOf: place => DefaultTokens.For(_tokenVault, place));
}
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
@@ -887,7 +885,7 @@ public sealed partial class MainWindow : Window
ForgeEndpoint place = seed.Place ?? ForgeEndpoint.GitHub;
var viewModel = new AccountWizardViewModel(
_accountsStore, UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place), seed, _log,
defaultToken: () => DefaultTokens.For(_tokenVault, place));
defaultTokenOf: where => DefaultTokens.For(_tokenVault, where));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
+26 -3
View File
@@ -77,7 +77,8 @@ public sealed partial class SettingsDialog : ContentDialog
_giteaState = new SettingsGiteaState(
giteaPlace,
hasSavedToken: giteaPlace is not null && DefaultTokens.For(vault, giteaPlace) is not null,
dependentAccounts: giteaPlace is null ? [] : accounts.UsingDefaultTokenOf(giteaPlace).Select(a => a.Name).ToList());
dependentAccounts: giteaPlace is null ? [] : accounts.UsingDefaultTokenOf(giteaPlace).Select(a => a.Name).ToList(),
holdsATokenFor: place => DefaultTokens.For(vault, place) is not null);
ArgumentNullException.ThrowIfNull(windowHandleProvider);
_settings = settings;
_vault = vault;
@@ -213,7 +214,26 @@ public sealed partial class SettingsDialog : ContentDialog
private void DefaultTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
=> UpdateTokenCaption();
{
DropTheMaskFromWhatWasTyped(DefaultTokenBox);
UpdateTokenCaption();
}
/// <summary>
/// A box that shows the mask and is typed into without being emptied first holds
/// the mask and then the typing: the mask is taken out, so that what is stored is
/// the token alone.
/// </summary>
private static void DropTheMaskFromWhatWasTyped(PasswordBox box)
{
string typed = SettingsTokenState.WithoutTheMask(box.Password);
if(!string.Equals(typed, box.Password, StringComparison.Ordinal))
{
box.Password = typed;
}
}
@@ -223,7 +243,10 @@ public sealed partial class SettingsDialog : ContentDialog
private void GiteaTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
=> UpdateGiteaCaption();
{
DropTheMaskFromWhatWasTyped(GiteaTokenBox);
UpdateGiteaCaption();
}