feat(app): Settings holds a default token per forge, and a default token is used only for the place it was saved for (#215)
Step 5 of Gitea support (#201). Settings was all about GitHub; GitHub and Gitea are equal options there too. - Settings has a "Default tokens" section with two groups, named as everywhere else: GitHub (token) and Gitea (the server's address and its token). The Gitea address and token are saved, replaced and removed together; the saved token is never carried to another address, and a token for an address that cannot be used is not saved (Save is withheld, with the reason). - The rule "the default token is a GitHub token" is replaced by: a default token belongs to one place and is never used for another. It is in the name of the vault entry: GitHub's is the one it always was, a Gitea server's is made from the server's address. Asking for the default token of a place finds the one saved for exactly that place, or none. - Quick Sync fills in the default token of the place on its card. - An account that uses the default token gets the one of its own place. The wizard offers the choice for a place that has one; accounts add and accounts edit take --use-default-token for any place that has one. - A workspace is told which place's default token changed, and takes only its own. - README, docs/CLI.md, ADR 0010, threat model T16. Included, from the reviews of this pull request: - fix(app): a default token is never left out of sight, and a wizard is given the default token of its own place only - fix(app): a window that closes does not put older settings back, and a token typed behind the mask is not rewritten in its box - fix(app): a default token that is gone when Save is pressed is not taken up, and the saved default is not given up for it Closes #212 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit was merged in pull request #215.
This commit is contained in:
1 parent
b7d54735c8
commit
d5d4f6763f
31 files changed
+1841
-207
No files matched your search
@@ -81,7 +81,7 @@ gclo authenticates to GitHub with a personal access token — it is used for bot
|
||||
| Classic PAT | `repo` scope (read access to the org's repositories); optionally `read:org` so the organization dropdown / `gclo orgs` can list your orgs |
|
||||
| Fine-grained PAT | Repository read access for the organization |
|
||||
|
||||
**Token storage.** In **Quick Sync**, the token is held in memory for the session only and never written anywhere. A **saved account's** token and the optional **default token** (Settings) are kept in the **Windows Credential Manager** (per-user, DPAPI-protected) — never in `settings.json`, `accounts.json`, or any log. See [SECURITY.md](SECURITY.md). A saved token is read at the moment it is used: replacing or removing the default token in Settings, or an account's token in its wizard, takes effect for the workspaces that are already open, without a restart. Quick Sync follows a changed default token only while its token box still holds the one Settings put there.
|
||||
**Token storage.** In **Quick Sync**, the token is held in memory for the session only and never written anywhere. A **saved account's** token and the optional **default tokens** (File > Settings: one for GitHub, and one for a Gitea server together with its address) are kept in the **Windows Credential Manager** (per-user, DPAPI-protected) — never in `settings.json`, `accounts.json`, or any log. See [SECURITY.md](SECURITY.md). A saved token is read at the moment it is used: replacing or removing the default token in Settings, or an account's token in its wizard, takes effect for the workspaces that are already open, without a restart. Quick Sync follows a changed default token only while its token box still holds the one Settings put there.
|
||||
|
||||
## Using the GUI
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
+5
-4
@@ -407,10 +407,11 @@ else: to move an account, remove it and add it again.
|
||||
With `--use-default-token` instead of a token option, the account holds no
|
||||
token of its own: it resolves to the default token saved in the desktop app's
|
||||
Settings every time it syncs, so replacing that token in Settings updates every
|
||||
such account at once. The command fails (exit 2) when no default token is saved,
|
||||
because the account could never sync; the CLI cannot set the default token.
|
||||
The default token is a GitHub token: `--use-default-token` is for GitHub
|
||||
accounts only, and an account on a Gitea server always has a token of its own.
|
||||
such account at once. Settings holds one default token for GitHub and one for a
|
||||
Gitea server; an account gets the one of the place it is on, and a default
|
||||
token is never used for another place. The command fails (exit 2) when no
|
||||
default token is saved for the account's place, because the account could never
|
||||
sync; the CLI cannot set a default token.
|
||||
|
||||
```powershell
|
||||
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
|
||||
|
||||
@@ -113,10 +113,11 @@ were the two lister implementations and the failure type they throw.
|
||||
hold for a file changed by hand: at another address the account has no token.
|
||||
The price: a token whose account was edited that way stays in Credential Manager
|
||||
under a name gclo no longer derives, until the address is put back.
|
||||
- **Tokens do not cross forges by default.** The default token saved in Settings
|
||||
is a GitHub token: an account on another forge has a token of its own, and is
|
||||
never handed the default one, whatever its record says. On the command line
|
||||
each forge has its own default variable (`GITHUB_TOKEN`, `GITEA_TOKEN`).
|
||||
- **Tokens do not cross places by default.** A default token saved in Settings
|
||||
belongs to one place (GitHub, or one Gitea server) and is never handed to an
|
||||
account on another, whatever its record says: see "A default token per place"
|
||||
below. On the command line each forge has its own default variable
|
||||
(`GITHUB_TOKEN`, `GITEA_TOKEN`).
|
||||
- **One place pairs an account with its listers** (`AccountListers`, over
|
||||
`ForgeEndpoint`), used by the app and the command line. An account whose stored
|
||||
forge or server cannot be used (a file edited by hand) gets listers that ask no
|
||||
@@ -137,8 +138,8 @@ were the two lister implementations and the failure type they throw.
|
||||
edit, a saved Quick Sync connection) offers no choice.
|
||||
- **What was entered for one place is not carried to another.** When step 1 is
|
||||
left with another place than before, the typed token, the organization and the
|
||||
list of organizations are emptied, and the default token (a GitHub token) is
|
||||
neither offered nor preselected for a Gitea server.
|
||||
list of organizations are emptied, and the default token on offer is the one
|
||||
saved for the new place, if there is one (#212).
|
||||
- **Texts follow the place.** The page a token is made on is the one of the
|
||||
chosen place (`ForgeTexts`); step 2 names the place the token is checked with.
|
||||
|
||||
@@ -173,6 +174,39 @@ matters most.
|
||||
- **The app opens where it was left:** the workspace that was open last; with
|
||||
nothing recorded, the first account; with no account, Quick Sync.
|
||||
|
||||
### A default token per place (#212)
|
||||
|
||||
Settings held one default token, GitHub's, and the rule was "the default token is
|
||||
a GitHub token". With GitHub and Gitea as equal options it holds one for GitHub
|
||||
and one for a Gitea server, and the rule is: **a default token belongs to one
|
||||
place and is never used for another.**
|
||||
|
||||
- **The rule is in the name of the vault entry**, not in a check somebody has to
|
||||
remember: GitHub's entry is the one it always was; a Gitea server's is made
|
||||
from the server's address (`DefaultTokens.EntryFor`). Asking for the default
|
||||
token of a place finds the one saved for exactly that place, or none.
|
||||
- **Who asks:** Quick Sync, for the place on its card; an account that uses the
|
||||
default token, for its own place (`AccountTokenResolver`); the wizard, for the
|
||||
place of the account; `--use-default-token` on the command line, likewise. A
|
||||
place that has none offers no such choice.
|
||||
- **The Gitea default is an address and a token, saved together.** Settings never
|
||||
carries the saved token to another address: a changed address asks for the
|
||||
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. 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. With another server saved as the default, taking
|
||||
one up removes that server's token, as typing a token for another server
|
||||
does: there is one default Gitea server.
|
||||
- What the app remembers by itself (the workspace that was open, Quick Sync's
|
||||
connection) is written over `settings.json` as it is on disk at that moment,
|
||||
those values only. A second gclo window that closes does not put its older
|
||||
copy of the settings back, which would drop an address saved in the meantime.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A Gitea client package.** One more dependency to track and to trust with the
|
||||
|
||||
@@ -100,8 +100,8 @@ flowchart LR
|
||||
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
|
||||
| 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; the default token (a GitHub token) is never handed to an account on another forge, 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 is not offered for, or sent to, a Gitea server. 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) |
|
||||
| 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, 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
|
||||
|
||||
@@ -136,3 +136,4 @@ flowchart LR
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); accounts on another forge are in `accounts-forges.json`, which an older gclo does not rewrite; their token is bound to the account and its server address. T13 restated for it. |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for the desktop app's Add account wizard (#206): the place is chosen before the token is typed, the token is checked with that place only, and nothing typed for one place is carried to another. |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for Quick Sync (#209): GitHub and Gitea are equal on the card the app opens on; nothing is preselected and nothing is sent without the user asking; `settings.json` now also holds the place Quick Sync last connected to (a server address, no token). |
|
||||
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 restated for a default token per place (#212): Settings holds one for GitHub and one for a Gitea server, each under a vault entry named after its place; `settings.json` holds the address of that server. The rule "the default token is a GitHub token" is replaced by "a default token is never used for another place than its own". |
|
||||
@@ -304,17 +304,31 @@ public sealed class ForgeCliTests : IDisposable
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsAdd_OnTheDefaultToken_IsForGitHubOnly()
|
||||
public void AccountsAdd_OnTheDefaultToken_NeedsTheDefaultTokenOfItsOwnPlace()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: it is not the server's
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, Server, out ForgeEndpoint? place, out _));
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
CliUsageException error = Assert.Throws<CliUsageException>(
|
||||
CliErrorException error = Assert.Throws<CliErrorException>(
|
||||
() => Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
|
||||
|
||||
Assert.Equal(
|
||||
"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on git.example.test a token of its own.",
|
||||
"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());
|
||||
|
||||
// With one saved for that server, the account uses it and has no token of its own.
|
||||
_vault.Store(DefaultTokens.EntryFor(place), "gitea_default");
|
||||
|
||||
Assert.Equal(0, Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
|
||||
|
||||
Account added = Assert.Single(Store.GetAll());
|
||||
Assert.Equal(TokenSource.Default, added.TokenSource);
|
||||
Assert.Equal(ForgeKind.Gitea, added.Forge);
|
||||
Assert.Null(_vault.TryRetrieve(added.TokenEntryId));
|
||||
Assert.Equal("gitea_default", new AccountTokenResolver(_vault).TryResolve(added));
|
||||
}
|
||||
|
||||
|
||||
@@ -354,18 +368,18 @@ public sealed class ForgeCliTests : IDisposable
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsEdit_AGiteaAccount_KeepsATokenOfItsOwn_AndATokenOptionReplacesIt()
|
||||
public void AccountsEdit_AGiteaAccount_KeepsATokenOfItsOwn_UnlessItsServerHasADefaultToken()
|
||||
{
|
||||
Account account = AddGiteaAccount();
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: it is not the server's
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, Server, out ForgeEndpoint? place, out _));
|
||||
Environment.SetEnvironmentVariable("GCLO_FORGE_TEST_TOKEN", "replaced");
|
||||
using var console = new ConsoleCapture();
|
||||
try
|
||||
{
|
||||
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
|
||||
Assert.Equal(
|
||||
"Account 'forge' is on git.example.test: the default token is a GitHub token, so it keeps a token of its own.",
|
||||
error.Message);
|
||||
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"));
|
||||
}
|
||||
@@ -377,6 +391,35 @@ public sealed class ForgeCliTests : IDisposable
|
||||
Assert.Equal("replaced", _vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.Equal(TokenSource.Own, Store.FindByName("forge")!.TokenSource);
|
||||
|
||||
// With a default token saved for its server, the account can be switched to it: its own token is removed.
|
||||
_vault.Store(DefaultTokens.EntryFor(place), "gitea_default");
|
||||
|
||||
Assert.Equal(0, Accounts("edit", "--name", "forge", "--use-default-token"));
|
||||
|
||||
Account switched = Store.FindByName("forge")!;
|
||||
Assert.Equal(TokenSource.Default, switched.TokenSource);
|
||||
Assert.Null(_vault.TryRetrieve(account.TokenEntryId));
|
||||
Assert.Equal("gitea_default", new AccountTokenResolver(_vault).TryResolve(switched));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountsEdit_OfAnAccountThatCannotBeUsed_DoesNotSwitchItToADefaultToken()
|
||||
{
|
||||
AddGiteaAccount();
|
||||
string file = Path.Combine(_dir, "accounts-forges.json");
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace(Server, "http://git.example.test/", StringComparison.Ordinal));
|
||||
_store = null; // read the file again
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
|
||||
|
||||
Assert.Equal(
|
||||
"Account 'forge' cannot be used as it is saved: the server address cannot be used: "
|
||||
+ "it has to begin with https://. Remove the account and add it again.",
|
||||
error.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
+23
-22
@@ -70,8 +70,9 @@ internal static class AccountsCommand
|
||||
the default token saved in the desktop app's
|
||||
Settings, read whenever it syncs, so replacing
|
||||
the default token there updates this account too.
|
||||
GitHub accounts only: the default token is a
|
||||
GitHub token.
|
||||
It is the default token of the place the account
|
||||
is on (GitHub, or its Gitea server): one has to
|
||||
be saved for that place.
|
||||
|
||||
edit
|
||||
--name <name> The account to change (required).
|
||||
@@ -84,9 +85,9 @@ internal static class AccountsCommand
|
||||
Replace the stored token (and switch an account
|
||||
on the default token to a token of its own);
|
||||
without a token option the token is left untouched.
|
||||
--use-default-token Switch the account to the default token; its own
|
||||
token is removed from Windows Credential Manager.
|
||||
GitHub accounts only.
|
||||
--use-default-token Switch the account to the default token of the
|
||||
place it is on; its own token is removed from
|
||||
Windows Credential Manager.
|
||||
The forge and the server of an account cannot be changed: its token was
|
||||
saved for that place. Remove the account and add it again.
|
||||
|
||||
@@ -276,11 +277,6 @@ internal static class AccountsCommand
|
||||
|
||||
string targetRoot = AbsoluteTarget(target);
|
||||
ForgeEndpoint endpoint = forgeOptions.Resolve();
|
||||
if(tokenOptions.UseDefault && endpoint.Kind != ForgeKind.GitHub)
|
||||
{
|
||||
throw new CliUsageException(
|
||||
$"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on {endpoint} a token of its own.");
|
||||
}
|
||||
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', forge={endpoint}, token={(tokenOptions.UseDefault ? "default" : "own")}");
|
||||
try
|
||||
@@ -289,7 +285,7 @@ internal static class AccountsCommand
|
||||
// opens, so a bad token option fails without touching accounts.json.
|
||||
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve(endpoint.Kind);
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault, endpoint);
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -406,12 +402,16 @@ internal static class AccountsCommand
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
|
||||
RefuseASharedName(store, existing.Name);
|
||||
if(tokenOptions.UseDefault && existing.Forge != ForgeKind.GitHub)
|
||||
tokenOptions.EnsureAtMostOneSource();
|
||||
if(tokenOptions.UseDefault)
|
||||
{
|
||||
throw new CliErrorException(
|
||||
$"Account '{existing.Name}' is on {Where(existing)}: the default token is a GitHub token, so it keeps a token of its own.");
|
||||
// The default token of the place the account is on; an account whose place cannot be used has none.
|
||||
ForgeEndpoint place = existing.TryGetEndpoint(out ForgeEndpoint? where, out string? unusable)
|
||||
? where
|
||||
: throw new CliErrorException(
|
||||
$"Account '{existing.Name}' cannot be used as it is saved: {unusable}. {existing.AdviceWhenUnusable("Remove")}");
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault, place);
|
||||
}
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve(existing.Forge) : null;
|
||||
|
||||
// A token option decides the source: --use-default-token switches to the
|
||||
@@ -535,18 +535,19 @@ internal static class AccountsCommand
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// --use-default-token is only useful when a default token exists: without one
|
||||
/// the account could never sync, so the command fails up front and says where
|
||||
/// the default token is set (the CLI has no way to set it).
|
||||
/// --use-default-token is only useful when a default token exists for the place
|
||||
/// the account is on: without one the account could never sync, so the command
|
||||
/// fails up front and says where the default token is set (the CLI has no way to
|
||||
/// set it). A default token saved for another place does not count.
|
||||
/// </summary>
|
||||
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault)
|
||||
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault, ForgeEndpoint place)
|
||||
{
|
||||
tokenOptions.EnsureAtMostOneSource();
|
||||
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultToken)
|
||||
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultTokenFor(place))
|
||||
{
|
||||
throw new CliErrorException(
|
||||
"No default token is saved, so the account could not sync. Save one in the desktop app "
|
||||
+ "(Settings > Default GitHub token), or give the account a token of its own with --token-env, "
|
||||
$"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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,14 +735,14 @@ public sealed class AccountFilesTests : IDisposable
|
||||
// ---------------------------------------------------------------- the wizard
|
||||
|
||||
[Fact]
|
||||
public async Task TheWizard_NeverSendsTheDefaultTokenToAnAccountOnAnotherForge_WhateverSetTheChoice()
|
||||
public async Task TheWizard_SendsNoDefaultTokenToAnAccountWhoseServerHasNone_WhateverSetTheChoice()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: the wizard of this account is not given it
|
||||
Account forge = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(forge, "gitea_token");
|
||||
var lister = new FakeOrganizationLister();
|
||||
var wizard = new AccountWizardViewModel(store, lister, new AppSettings(), forge, () => "gitea_token", defaultToken: () => "ghp_default");
|
||||
var wizard = new AccountWizardViewModel(store, lister, new AppSettings(), forge, () => "gitea_token", defaultToken: () => null);
|
||||
|
||||
wizard.TokenChoiceIndex = 0; // the choice is not shown for this account; this sets it all the same
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
@@ -750,7 +750,9 @@ public sealed class AccountFilesTests : IDisposable
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
|
||||
Assert.Equal(2, wizard.Step);
|
||||
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
|
||||
Assert.Equal(
|
||||
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
|
||||
wizard.TokenError);
|
||||
Assert.Equal(0, lister.Calls);
|
||||
}
|
||||
}
|
||||
@@ -144,14 +144,17 @@ public sealed class AccountForgeTests : IDisposable
|
||||
// ---------------------------------------------------------------- the default token is GitHub's
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultToken_IsNeverGivenToAnAccountOnAnotherForge()
|
||||
public void TheDefaultTokenOfGitHub_IsNeverGivenToAnAccountOnAGiteaServer()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
Account gitea = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
|
||||
Account gitea = GiteaAccount() with { TokenSource = TokenSource.Default };
|
||||
|
||||
// A default token belongs to one place: the server has none, and GitHub's is not it.
|
||||
Assert.Null(resolver.TryResolve(gitea));
|
||||
Assert.Contains("the default token, which is a GitHub token", resolver.MissingDescription(gitea), StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
"uses the default token for git.example.test, but none is saved for it (desktop app: Settings)",
|
||||
resolver.MissingDescription(gitea));
|
||||
Assert.Equal("ghp_default", resolver.TryResolve(GitHubAccount() with { TokenSource = TokenSource.Default }));
|
||||
}
|
||||
|
||||
@@ -171,14 +174,17 @@ public sealed class AccountForgeTests : IDisposable
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_AGiteaAccountOnTheDefaultToken_IsRefused()
|
||||
public void Save_AGiteaAccountOnTheDefaultTokenOfItsServer_HasNoTokenOfItsOwn()
|
||||
{
|
||||
Account account = GiteaAccount() with { TokenSource = TokenSource.Default };
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, token: null));
|
||||
NewStore().Save(account, token: null);
|
||||
|
||||
Assert.Contains("is on git.example.test; the default token is a GitHub token", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(NewStore().GetAll());
|
||||
Account saved = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(TokenSource.Default, saved.TokenSource);
|
||||
Assert.Equal(ForgeKind.Gitea, saved.Forge);
|
||||
Assert.Null(_vault.TryRetrieve(saved.TokenEntryId));
|
||||
Assert.Throws<ArgumentException>(() => NewStore().Save(account, "a token it has no place for")); // as for any account on a default token
|
||||
}
|
||||
|
||||
|
||||
@@ -328,10 +334,11 @@ public sealed class AccountForgeTests : IDisposable
|
||||
Account account = GiteaAccount();
|
||||
AccountsStore store = NewStore();
|
||||
store.Save(account, "gitea_token");
|
||||
// The wizard of an account is given the default token of the account's place: this server has none.
|
||||
var wizard = new AccountWizardViewModel(
|
||||
store, new FakeOrganizationLister(), new AppSettings(), account, () => "gitea_token", defaultToken: () => "ghp_default");
|
||||
store, new FakeOrganizationLister(), new AppSettings(), account, () => "gitea_token", defaultToken: () => null);
|
||||
|
||||
// The default token is a GitHub token: it is not on offer here.
|
||||
// GitHub's default token is not on offer for an account on a Gitea server.
|
||||
Assert.False(wizard.OffersTokenChoice);
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
|
||||
@@ -352,15 +359,26 @@ public sealed class AccountForgeTests : IDisposable
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheWizard_ForAnAccountOnAnotherForgeThatSaysDefaultToken_DoesNotPreselectIt()
|
||||
public async Task TheWizard_ForAGiteaAccountOnTheDefaultToken_WhoseServerHasNone_SendsNothing_AndOffersATokenOfItsOwn()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Account edited = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); // GitHub's: not this account's
|
||||
Account account = GiteaAccount() with { TokenSource = TokenSource.Default };
|
||||
var lister = new FakeOrganizationLister();
|
||||
|
||||
var wizard = new AccountWizardViewModel(
|
||||
NewStore(), new FakeOrganizationLister(), new AppSettings(), edited, () => null, defaultToken: () => "ghp_default");
|
||||
NewStore(), lister, new AppSettings(), account, () => null, defaultToken: () => null);
|
||||
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.False(wizard.OffersTokenChoice);
|
||||
// It says it uses the default token, and there is none for its server: the choice is shown, so it can be given a token of its own.
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
Assert.True(wizard.OffersTokenChoice);
|
||||
Assert.False(wizard.HasDefaultToken);
|
||||
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
|
||||
Assert.Equal(
|
||||
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
|
||||
wizard.TokenError);
|
||||
Assert.Equal(0, lister.Calls);
|
||||
}
|
||||
}
|
||||
@@ -114,11 +114,11 @@ public sealed class AccountTokenResolverTests
|
||||
public void HasDefaultToken_ReflectsTheVault()
|
||||
{
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
Assert.False(resolver.HasDefaultToken);
|
||||
Assert.False(resolver.HasDefaultTokenFor(ForgeEndpoint.GitHub));
|
||||
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.True(resolver.HasDefaultToken);
|
||||
Assert.True(resolver.HasDefaultTokenFor(ForgeEndpoint.GitHub));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ public sealed class AccountWizardPlaceTests : IDisposable
|
||||
|
||||
|
||||
private AccountWizardViewModel ChoosingWizard(string? defaultToken = null)
|
||||
=> new(_store, ListerOf, _defaults, defaultToken: () => defaultToken);
|
||||
=> new(_store, ListerOf, _defaults, defaultTokenOf: place => place.Kind == ForgeKind.GitHub ? defaultToken : null);
|
||||
|
||||
|
||||
|
||||
@@ -538,10 +538,12 @@ public sealed class AccountWizardPlaceTests : IDisposable
|
||||
Assert.Equal("Enter a token to continue.", wizard.TokenError);
|
||||
Assert.Empty(_sent);
|
||||
|
||||
// The choice is not shown here. Set all the same, it is refused: nothing is sent.
|
||||
// The choice is not shown here: the server has no default token. Set all the same, there is nothing to send.
|
||||
wizard.TokenChoiceIndex = 0;
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
|
||||
Assert.Equal(
|
||||
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
|
||||
wizard.TokenError);
|
||||
Assert.Empty(_sent);
|
||||
|
||||
// Back on GitHub, it is on offer again, and preselected as for any new account.
|
||||
|
||||
@@ -488,7 +488,7 @@ public sealed class AccountWizardViewModelTests : IDisposable
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
Assert.Equal(1, _store.CountUsingDefaultToken());
|
||||
Assert.Single(_store.UsingDefaultTokenOf(ForgeEndpoint.GitHub));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -138,13 +138,13 @@ public sealed class AccountsStoreTests : IDisposable
|
||||
public void CountUsingDefaultToken_CountsOnlyDefaultTokenAccounts()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Assert.Equal(0, store.CountUsingDefaultToken());
|
||||
Assert.Empty(store.UsingDefaultTokenOf(ForgeEndpoint.GitHub));
|
||||
store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null);
|
||||
store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null);
|
||||
store.Save(MakeAccount("C"), "ghp_c");
|
||||
|
||||
Assert.Equal(2, store.CountUsingDefaultToken());
|
||||
Assert.Equal(2, NewStore().CountUsingDefaultToken());
|
||||
Assert.Equal(2, store.UsingDefaultTokenOf(ForgeEndpoint.GitHub).Count);
|
||||
Assert.Equal(2, NewStore().UsingDefaultTokenOf(ForgeEndpoint.GitHub).Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- persistence
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A default token per forge (#212): Settings holds one for GitHub and one for a
|
||||
/// Gitea server. The rule these tests are built around replaces "the default token
|
||||
/// is a GitHub token": a default token belongs to one place and is never used for
|
||||
/// another. Quick Sync fills in the one of the place on its card, an account that
|
||||
/// uses the default token gets the one of its own place, and the Gitea one is
|
||||
/// saved, replaced and removed together with the address of its server.
|
||||
/// </summary>
|
||||
public sealed class DefaultTokenPerPlaceTests : IDisposable
|
||||
{
|
||||
private const string Server = "https://git.example.test/";
|
||||
|
||||
private const string OtherServer = "https://other.example.test/";
|
||||
|
||||
private static readonly ForgeEndpoint s_gitea = Place(Server);
|
||||
|
||||
private static readonly ForgeEndpoint s_other = Place(OtherServer);
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly InMemoryVault _vault = new();
|
||||
|
||||
|
||||
|
||||
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
|
||||
|
||||
|
||||
|
||||
private static ForgeEndpoint Place(string server)
|
||||
{
|
||||
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, server, out ForgeEndpoint? place, out _));
|
||||
return place;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private AccountsStore NewStore() => new(_vault, _root);
|
||||
|
||||
|
||||
|
||||
private static Account AccountOn(ForgeEndpoint place, string name, TokenSource source = TokenSource.Default) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos",
|
||||
Forge = place.Kind,
|
||||
Server = place.Server?.AbsoluteUri,
|
||||
TokenSource = source,
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- where a default token is kept
|
||||
|
||||
[Fact]
|
||||
public void TheEntryOfADefaultToken_IsGitHubsAsEver_AndForAGiteaServerMadeOfItsAddress()
|
||||
{
|
||||
Assert.Equal(AppSettings.DefaultTokenVaultId, DefaultTokens.EntryFor(ForgeEndpoint.GitHub));
|
||||
|
||||
// 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!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ADefaultToken_IsFoundForThePlaceItWasSavedFor_AndForNoOther()
|
||||
{
|
||||
Assert.Null(DefaultTokens.For(_vault, ForgeEndpoint.GitHub));
|
||||
Assert.Null(DefaultTokens.For(_vault, s_gitea));
|
||||
|
||||
_vault.Store(DefaultTokens.EntryFor(ForgeEndpoint.GitHub), "ghp_default");
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_default");
|
||||
|
||||
Assert.Equal("ghp_default", DefaultTokens.For(_vault, ForgeEndpoint.GitHub));
|
||||
Assert.Equal("gitea_default", DefaultTokens.For(_vault, s_gitea));
|
||||
Assert.Null(DefaultTokens.For(_vault, s_other));
|
||||
Assert.Throws<ArgumentNullException>(() => DefaultTokens.For(null!, ForgeEndpoint.GitHub));
|
||||
|
||||
_vault.Store(DefaultTokens.EntryFor(s_other), "");
|
||||
Assert.Null(DefaultTokens.For(_vault, s_other)); // an empty entry is no token
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- an account on the default token
|
||||
|
||||
[Fact]
|
||||
public void AnAccountOnTheDefaultToken_GetsTheOneOfItsOwnPlace_AndNeverAnothers()
|
||||
{
|
||||
_vault.Store(DefaultTokens.EntryFor(ForgeEndpoint.GitHub), "ghp_default");
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_default");
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
|
||||
Assert.Equal("ghp_default", resolver.TryResolve(AccountOn(ForgeEndpoint.GitHub, "Work")));
|
||||
Assert.Equal("gitea_default", resolver.TryResolve(AccountOn(s_gitea, "Forge")));
|
||||
|
||||
// A server that has no default token gets none: neither GitHub's nor the other server's.
|
||||
Account elsewhere = AccountOn(s_other, "Elsewhere");
|
||||
Assert.Null(resolver.TryResolve(elsewhere));
|
||||
Assert.Equal(
|
||||
"uses the default token for other.example.test, but none is saved for it (desktop app: Settings)",
|
||||
resolver.MissingDescription(elsewhere));
|
||||
|
||||
// An account whose place cannot be used has no place to have a default token of.
|
||||
Assert.Null(resolver.TryResolve(AccountOn(s_gitea, "Broken") with { Server = "http://git.example.test" }));
|
||||
Assert.Null(resolver.TryResolve(AccountOn(s_gitea, "Later") with { ForgeName = "GitLab" }));
|
||||
|
||||
Assert.True(resolver.HasDefaultTokenFor(ForgeEndpoint.GitHub));
|
||||
Assert.True(resolver.HasDefaultTokenFor(s_gitea));
|
||||
Assert.False(resolver.HasDefaultTokenFor(s_other));
|
||||
|
||||
_vault.Delete(DefaultTokens.EntryFor(ForgeEndpoint.GitHub));
|
||||
Assert.Equal(
|
||||
"uses the default token, but no default token is saved (desktop app: Settings)",
|
||||
resolver.MissingDescription(AccountOn(ForgeEndpoint.GitHub, "Work")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AnAccountOnAGiteaServer_CanBeSavedAsUsingTheDefaultToken_AndIsCountedForItsPlaceOnly()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account work = AccountOn(ForgeEndpoint.GitHub, "Work");
|
||||
Account forge = AccountOn(s_gitea, "Forge");
|
||||
Account elsewhere = AccountOn(s_other, "Elsewhere");
|
||||
Account own = AccountOn(s_gitea, "Own", TokenSource.Own);
|
||||
store.Save(work, token: null);
|
||||
store.Save(forge, token: null);
|
||||
store.Save(elsewhere, token: null);
|
||||
store.Save(own, "gitea_own");
|
||||
|
||||
Assert.Equal(TokenSource.Default, NewStore().FindByName("Forge")!.TokenSource);
|
||||
Assert.Equal(["Work"], store.UsingDefaultTokenOf(ForgeEndpoint.GitHub).Select(a => a.Name), StringComparer.Ordinal);
|
||||
Assert.Equal(["Forge"], store.UsingDefaultTokenOf(s_gitea).Select(a => a.Name), StringComparer.Ordinal);
|
||||
Assert.Equal(["Elsewhere"], store.UsingDefaultTokenOf(s_other).Select(a => a.Name), StringComparer.Ordinal);
|
||||
Assert.Throws<ArgumentNullException>(() => store.UsingDefaultTokenOf(null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- Settings: the Gitea group
|
||||
|
||||
[Fact]
|
||||
public void WithNothingSaved_TheGiteaGroup_SavesAnAddressAndItsTokenTogether()
|
||||
{
|
||||
var state = new SettingsGiteaState(savedPlace: null, hasSavedToken: false, dependentAccounts: []);
|
||||
|
||||
Assert.False(state.HasSavedToken);
|
||||
Assert.Equal("", state.InitialServer);
|
||||
Assert.Equal("", state.InitialBoxValue);
|
||||
Assert.False(state.CanOfferRemoval(""));
|
||||
|
||||
// Nothing entered: nothing to do.
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, null, ""), state.Plan("", ""));
|
||||
Assert.Equal("No default token saved for a Gitea server.", state.Caption("", ""));
|
||||
Assert.False(state.IsWarning("", ""));
|
||||
|
||||
// An address alone is no default.
|
||||
Assert.Equal(SettingsTokenAction.Keep, state.Plan("git.example.test", "").Action);
|
||||
Assert.False(state.Plan("git.example.test", "").IsError);
|
||||
|
||||
// A token without an address has no server to be for.
|
||||
SettingsGiteaPlan noAddress = state.Plan(" ", "gitea_typed");
|
||||
Assert.True(noAddress.IsError);
|
||||
Assert.Equal("Enter the address of the server this token is for.", noAddress.Error);
|
||||
Assert.Equal(noAddress.Error, state.Caption(" ", "gitea_typed"));
|
||||
Assert.True(state.IsWarning(" ", "gitea_typed"));
|
||||
|
||||
// An address that cannot be used is said so, and not repeated.
|
||||
SettingsGiteaPlan refused = state.Plan("https://bot:hunter2@git.example.test", "gitea_typed");
|
||||
Assert.True(refused.IsError);
|
||||
Assert.Equal("The server address cannot be used: it contains a user name or a password; the token is entered separately.", refused.Error);
|
||||
|
||||
// Both: stored for that server, as its address is normalized.
|
||||
SettingsGiteaPlan store = state.Plan("git.example.test", "gitea_typed");
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), store);
|
||||
Assert.Equal(
|
||||
"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();
|
||||
Assert.Equal("default token for git.example.test stored", state.Apply(store, "gitea_typed", _vault, settings));
|
||||
Assert.Equal(Server, settings.DefaultGiteaServer);
|
||||
Assert.Equal(s_gitea, settings.DefaultGiteaPlace());
|
||||
Assert.Equal("gitea_typed", DefaultTokens.For(_vault, s_gitea));
|
||||
Assert.Null(DefaultTokens.For(_vault, ForgeEndpoint.GitHub)); // GitHub's is another entry
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WithATokenSaved_TheGiteaGroup_KeepsReplacesAndRemovesIt_ForItsServer()
|
||||
{
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
|
||||
var settings = new AppSettings { DefaultGiteaServer = Server };
|
||||
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: ["Forge", "Mirror"]);
|
||||
|
||||
Assert.True(state.HasSavedToken);
|
||||
Assert.Equal(Server, state.InitialServer);
|
||||
Assert.Equal(SettingsTokenState.Mask, state.InitialBoxValue); // never the token
|
||||
Assert.True(state.CanOfferRemoval(SettingsTokenState.Mask));
|
||||
Assert.False(state.CanOfferRemoval("typed"));
|
||||
|
||||
// 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 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));
|
||||
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea));
|
||||
|
||||
// The same server spelled another way is the same server.
|
||||
Assert.Equal(SettingsTokenAction.Keep, state.Plan("HTTPS://GIT.example.test", SettingsTokenState.Mask).Action);
|
||||
|
||||
// Typed over: replaced, for the same server.
|
||||
SettingsGiteaPlan replace = state.Plan(Server, "gitea_new");
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), replace);
|
||||
Assert.Equal(
|
||||
"The default token will be replaced on Save. The 2 accounts that use it switch to the new token too.",
|
||||
state.Caption(Server, "gitea_new"));
|
||||
Assert.False(state.IsWarning(Server, "gitea_new"));
|
||||
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), ("", "")])
|
||||
{
|
||||
SettingsGiteaPlan remove = state.Plan(server, box);
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Remove, s_gitea, ""), remove);
|
||||
Assert.Equal(
|
||||
"The default token will be removed on Save. 2 accounts use it and will stop syncing until a new one is saved: Forge, Mirror.",
|
||||
state.Caption(server, box));
|
||||
Assert.True(state.IsWarning(server, box));
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
"default token for git.example.test removed (2 account(s) depend on it)",
|
||||
state.Apply(state.Plan(Server, ""), "", _vault, settings));
|
||||
Assert.Null(DefaultTokens.For(_vault, s_gitea));
|
||||
Assert.Null(settings.DefaultGiteaServer);
|
||||
Assert.Null(settings.DefaultGiteaPlace());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheSavedGiteaToken_IsNeverCarriedToAnotherAddress()
|
||||
{
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
|
||||
var settings = new AppSettings { DefaultGiteaServer = Server };
|
||||
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: ["Forge"]);
|
||||
|
||||
// Another address with the saved token still in the box: refused, and Save is not offered.
|
||||
SettingsGiteaPlan refused = state.Plan(OtherServer, SettingsTokenState.Mask);
|
||||
Assert.True(refused.IsError);
|
||||
Assert.Equal(
|
||||
"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));
|
||||
Assert.Equal("gitea_saved", DefaultTokens.For(_vault, s_gitea));
|
||||
Assert.Null(DefaultTokens.For(_vault, s_other));
|
||||
|
||||
// With the token of the new server typed: stored for it, and the old server's is removed.
|
||||
SettingsGiteaPlan move = state.Plan(OtherServer, "other_typed");
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_other, ""), move);
|
||||
Assert.Equal(
|
||||
"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"));
|
||||
|
||||
Assert.Equal("default token for other.example.test stored", state.Apply(move, "other_typed", _vault, settings));
|
||||
Assert.Equal("other_typed", DefaultTokens.For(_vault, s_other));
|
||||
Assert.Null(DefaultTokens.For(_vault, s_gitea));
|
||||
Assert.Equal(OtherServer, settings.DefaultGiteaServer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ATokenTypedBehindTheMask_IsStoredWithoutTheMask()
|
||||
{
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
|
||||
var settings = new AppSettings { DefaultGiteaServer = Server };
|
||||
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: []);
|
||||
string box = SettingsTokenState.Mask + "gitea_new"; // typed into the box without emptying it first
|
||||
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Store, s_gitea, ""), state.Plan(Server, box));
|
||||
Assert.Equal("The default token will be replaced on Save.", state.Caption(Server, box));
|
||||
Assert.Equal("default token for git.example.test replaced", state.Apply(state.Plan(Server, box), box, _vault, settings));
|
||||
Assert.Equal("gitea_new", DefaultTokens.For(_vault, s_gitea));
|
||||
|
||||
// A part of the mask deleted and nothing typed: no token was entered, so the saved one is removed, and it says so.
|
||||
Assert.Equal(SettingsTokenAction.Remove, state.Plan(Server, SettingsTokenState.Mask[1..]).Action);
|
||||
Assert.Equal("The default token will be removed on Save.", state.Caption(Server, SettingsTokenState.Mask[1..]));
|
||||
|
||||
// The same with another address, or none: what is left of the mask is no token for that server either.
|
||||
Assert.True(state.Plan(OtherServer, SettingsTokenState.Mask[1..]).IsError);
|
||||
Assert.Equal(SettingsTokenAction.Remove, state.Plan("", SettingsTokenState.Mask[1..]).Action);
|
||||
Assert.Throws<ArgumentNullException>(() => state.Plan(Server, null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void ATokenThatIsGoneWhenSaveIsPressed_IsNotTakenUp_AndTheSavedDefaultIsNotGivenUpForIt(bool anotherIsSaved)
|
||||
{
|
||||
_vault.Store(DefaultTokens.EntryFor(s_other), "other_saved");
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_lost");
|
||||
var settings = new AppSettings { DefaultGiteaServer = anotherIsSaved ? OtherServer : null };
|
||||
var state = new SettingsGiteaState(
|
||||
anotherIsSaved ? s_other : null, hasSavedToken: anotherIsSaved, dependentAccounts: [],
|
||||
holdsATokenFor: place => DefaultTokens.For(_vault, place) is not null);
|
||||
SettingsGiteaPlan takeUp = state.Plan(Server, "");
|
||||
Assert.True(takeUp.TakesUp);
|
||||
|
||||
// Between the address being typed and Save, the token is removed elsewhere.
|
||||
_vault.Delete(DefaultTokens.EntryFor(s_gitea));
|
||||
|
||||
Assert.Equal(
|
||||
"default token for git.example.test not taken up: it is no longer saved; nothing was changed",
|
||||
state.Apply(takeUp, "", _vault, settings));
|
||||
Assert.Equal(anotherIsSaved ? OtherServer : null, settings.DefaultGiteaServer);
|
||||
Assert.Equal("other_saved", DefaultTokens.For(_vault, s_other));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ATokenTheVaultStillHolds_IsTakenUp_InPlaceOfTheDefaultThatIsSaved()
|
||||
{
|
||||
// The default is another server; the vault still holds a token for a server Settings lost sight of.
|
||||
_vault.Store(DefaultTokens.EntryFor(s_other), "other_saved");
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_lost");
|
||||
var settings = new AppSettings { DefaultGiteaServer = OtherServer };
|
||||
var asked = new List<ForgeEndpoint>();
|
||||
var state = new SettingsGiteaState(
|
||||
s_other, hasSavedToken: true, dependentAccounts: ["Mirror"],
|
||||
holdsATokenFor: place =>
|
||||
{
|
||||
asked.Add(place);
|
||||
return DefaultTokens.For(_vault, place) is not null;
|
||||
});
|
||||
|
||||
// A server that has no token: refused as before, the saved token is not carried there.
|
||||
Assert.True(state.Plan("https://third.example.test", SettingsTokenState.Mask).IsError);
|
||||
|
||||
// The server whose token is there, with the box as it was or emptied: taken up, and it says what goes.
|
||||
foreach(string box in (string[])[SettingsTokenState.Mask, ""])
|
||||
{
|
||||
SettingsGiteaPlan takeUp = state.Plan(Server, box);
|
||||
Assert.Equal(new SettingsGiteaPlan(SettingsTokenAction.Keep, s_gitea, "", TakesUp: true), takeUp);
|
||||
Assert.Equal(
|
||||
"A default token is already saved for https://git.example.test/. Save makes this server the Gitea default again, "
|
||||
+ "and removes the one for https://other.example.test/; open Settings afterwards to replace or remove the token."
|
||||
+ " 1 account uses it and will stop syncing until a new one is saved: Mirror.",
|
||||
state.Caption(Server, box));
|
||||
Assert.True(state.IsWarning(Server, box));
|
||||
}
|
||||
|
||||
// The vault was asked once for each server, however often the plan was made.
|
||||
Assert.Equal(2, asked.Count);
|
||||
|
||||
Assert.Equal(
|
||||
"default token for git.example.test taken up again (it was already saved), and the one for other.example.test removed",
|
||||
state.Apply(state.Plan(Server, SettingsTokenState.Mask), SettingsTokenState.Mask, _vault, settings));
|
||||
Assert.Equal(Server, settings.DefaultGiteaServer);
|
||||
Assert.Equal("gitea_lost", DefaultTokens.For(_vault, s_gitea)); // the token itself is not touched
|
||||
Assert.Null(DefaultTokens.For(_vault, s_other)); // one default Gitea server
|
||||
|
||||
// With no account on the saved default, taking another one up is no warning.
|
||||
var unused = new SettingsGiteaState(s_other, hasSavedToken: true, dependentAccounts: [], holdsATokenFor: _ => true);
|
||||
Assert.False(unused.IsWarning(Server, SettingsTokenState.Mask));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[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()
|
||||
{
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_saved");
|
||||
var settings = new AppSettings { DefaultGiteaServer = Server };
|
||||
var state = new SettingsGiteaState(s_gitea, hasSavedToken: true, dependentAccounts: []);
|
||||
|
||||
Assert.Equal("The default token will be removed on Save.", state.Caption(Server, ""));
|
||||
Assert.False(state.IsWarning(Server, ""));
|
||||
Assert.Equal("default token for git.example.test removed", state.Apply(state.Plan(Server, ""), "", _vault, settings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AServerThatIsRememberedWithoutItsToken_IsNoDefault()
|
||||
{
|
||||
// The address is in the settings, the vault holds nothing for it (it was removed by hand, or on another machine).
|
||||
var state = new SettingsGiteaState(s_gitea, hasSavedToken: false, dependentAccounts: []);
|
||||
|
||||
Assert.False(state.HasSavedToken);
|
||||
Assert.Equal("", state.InitialServer);
|
||||
Assert.Equal(SettingsTokenAction.Keep, state.Plan("", "").Action);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheGiteaGroup_NeedsItsArguments()
|
||||
{
|
||||
var state = new SettingsGiteaState(savedPlace: null, hasSavedToken: false, dependentAccounts: []);
|
||||
var nothing = new SettingsGiteaPlan(SettingsTokenAction.Keep, null, "");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => state.Plan(null!, ""));
|
||||
Assert.Throws<ArgumentNullException>(() => state.Plan("", null!));
|
||||
Assert.Throws<ArgumentNullException>(() => state.Apply(null!, "", _vault, new AppSettings()));
|
||||
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: []));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://git.example.test")] // a file edited by hand
|
||||
public void ADefaultGiteaServerThatIsNoUsableAddress_IsNoPlace(string? stored)
|
||||
{
|
||||
Assert.Null(new AppSettings { DefaultGiteaServer = stored }.DefaultGiteaPlace());
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- the wizard
|
||||
|
||||
[Fact]
|
||||
public async Task TheWizard_OffersTheDefaultTokenOfThePlaceThatIsChosen_AndUsesItThereOnly()
|
||||
{
|
||||
var sent = new List<(string Place, string Token)>();
|
||||
AccountsStore store = NewStore();
|
||||
var wizard = new AccountWizardViewModel(
|
||||
store,
|
||||
place => new FakeOrganizationLister
|
||||
{
|
||||
Handler = (token, _) =>
|
||||
{
|
||||
sent.Add((place.DisplayName, token));
|
||||
return Task.FromResult<IReadOnlyList<string>>(["platform"]);
|
||||
},
|
||||
},
|
||||
new AppSettings(),
|
||||
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea_default" : place.Kind == ForgeKind.GitHub ? "ghp_default" : null);
|
||||
|
||||
// On GitHub: GitHub's default token.
|
||||
Assert.True(wizard.HasDefaultToken);
|
||||
Assert.True(wizard.OffersTokenChoice);
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
|
||||
// On a server that has none: no choice, and a token of its own is asked for.
|
||||
wizard.Name = "Forge";
|
||||
wizard.IsOnGitea = true;
|
||||
wizard.Server = OtherServer;
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
Assert.False(wizard.HasDefaultToken);
|
||||
Assert.False(wizard.OffersTokenChoice);
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
wizard.TokenChoiceIndex = 0; // the choice is not shown here; set all the same, there is nothing to send
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
Assert.Equal(
|
||||
"The default token is no longer saved. Save one in Settings, or use a different token for this account.",
|
||||
wizard.TokenError);
|
||||
Assert.Empty(sent);
|
||||
|
||||
// On the server that has one: offered, preselected, checked there, and the account uses it.
|
||||
wizard.GoBack();
|
||||
wizard.Server = Server;
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
Assert.True(wizard.HasDefaultToken);
|
||||
Assert.True(wizard.OffersTokenChoice);
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
Assert.Equal("Used by Quick Sync. Replacing it in Settings updates this account too.", wizard.DefaultTokenCaption);
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
Assert.Equal([("git.example.test", "gitea_default")], sent);
|
||||
|
||||
wizard.Organization = "platform";
|
||||
Assert.True(await wizard.TryAdvanceAsync());
|
||||
wizard.TargetRoot = @"C:\mirror";
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Account saved = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(ForgeKind.Gitea, saved.Forge);
|
||||
Assert.Equal(TokenSource.Default, saved.TokenSource);
|
||||
Assert.Null(_vault.TryRetrieve(saved.TokenEntryId)); // it has no token of its own
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheWizard_EditingAnAccountOnTheDefaultTokenOfItsServer_CountsTheOthersOnThatServer()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account forge = AccountOn(s_gitea, "Forge");
|
||||
store.Save(forge, token: null);
|
||||
store.Save(AccountOn(s_gitea, "Mirror"), token: null);
|
||||
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,
|
||||
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea_default" : null);
|
||||
|
||||
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]
|
||||
public void QuickSync_IsToldOfAChangedDefaultToken_OnlyForThePlaceItIsOn()
|
||||
{
|
||||
using var workspace = new WorkspaceViewModel(
|
||||
git: new FakeGitClient(), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: new NullActivityLog(), wsl: new FakeWslCloner(),
|
||||
listersOf: _ => (new FakeRepositoryLister(), new FakeOrganizationLister()),
|
||||
defaultTokenOf: place => DefaultTokens.For(_vault, place));
|
||||
_vault.Store(DefaultTokens.EntryFor(s_gitea), "gitea_default");
|
||||
|
||||
workspace.IsOnGitea = true;
|
||||
workspace.Server = Server;
|
||||
|
||||
Assert.Equal("gitea_default", workspace.Token); // filled in for the server on the card
|
||||
|
||||
workspace.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "gitea_default", current: "ghp_new");
|
||||
workspace.OnDefaultTokenChanged(s_other, previous: "gitea_default", current: "other_new");
|
||||
Assert.Equal("gitea_default", workspace.Token); // another place's default: not this card's
|
||||
|
||||
workspace.OnDefaultTokenChanged(s_gitea, previous: "gitea_default", current: "gitea_new");
|
||||
Assert.Equal("gitea_new", workspace.Token);
|
||||
|
||||
workspace.OnDefaultTokenChanged(s_gitea, previous: "gitea_new", current: null);
|
||||
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) => inner.DescribeEntry(accountId);
|
||||
}
|
||||
}
|
||||
@@ -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,25 +815,25 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AChangeOfTheDefaultTokenInSettings_ReachesQuickSync_OnlyWhileItIsOnGitHub()
|
||||
public void AChangeOfGitHubsDefaultToken_ReachesQuickSync_OnlyWhileItIsOnGitHub()
|
||||
{
|
||||
WorkspaceViewModel workspace = QuickSync();
|
||||
|
||||
workspace.OnDefaultTokenChanged(previous: null, current: "ghp_default_0123456789");
|
||||
workspace.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: "ghp_default_0123456789");
|
||||
Assert.Equal("", workspace.Token); // no place was chosen yet
|
||||
|
||||
workspace.PlaceIndex = 0;
|
||||
workspace.OnDefaultTokenChanged(previous: null, current: "ghp_default_0123456789");
|
||||
workspace.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: "ghp_default_0123456789");
|
||||
Assert.Equal("ghp_default_0123456789", workspace.Token);
|
||||
|
||||
workspace.IsOnGitea = true;
|
||||
workspace.Server = Server;
|
||||
workspace.OnDefaultTokenChanged(previous: null, current: "ghp_another_0123456789"); // the box is empty, as "previous" says
|
||||
workspace.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: "ghp_another_0123456789"); // the box is empty, as "previous" says
|
||||
|
||||
Assert.Equal("", workspace.Token); // a GitHub token: not for this place
|
||||
|
||||
workspace.Server = "http://not-usable";
|
||||
workspace.OnDefaultTokenChanged(previous: null, current: "ghp_another_0123456789");
|
||||
workspace.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: "ghp_another_0123456789");
|
||||
|
||||
Assert.Equal("", workspace.Token);
|
||||
}
|
||||
@@ -866,16 +925,21 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
Assert.Contains("Place = git.example.test", seed.ToString(), StringComparison.Ordinal);
|
||||
Assert.Contains("Place = GitHub", (seed with { Place = null }).ToString(), StringComparison.Ordinal);
|
||||
|
||||
// The wizard that saves it: on that place, with that place's lister, and never on the default token (a GitHub token).
|
||||
// The wizard that saves it: on that place, with that place's lister. The server has no default token, so the
|
||||
// account gets the token of the connection as its own.
|
||||
var vault = new InMemoryVault();
|
||||
var store = new AccountsStore(vault, _root);
|
||||
var wizard = new AccountWizardViewModel(
|
||||
store, ListersOf(s_gitea).Organizations, seed, defaultToken: () => "gitea-token-0123456789");
|
||||
var wizard = new AccountWizardViewModel(store, ListersOf(s_gitea).Organizations, seed, defaultToken: () => null);
|
||||
Assert.False(wizard.CanChoosePlace);
|
||||
Assert.Equal("This account is on git.example.test.", wizard.PlaceNote);
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.False(wizard.OffersTokenChoice);
|
||||
|
||||
// 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,
|
||||
defaultTokenOf: place => place.Equals(s_gitea) ? "gitea-token-0123456789" : null).UseDefaultToken);
|
||||
|
||||
wizard.Name = "Platform";
|
||||
while(wizard.Step < 4)
|
||||
{
|
||||
@@ -1011,6 +1075,53 @@ public sealed class QuickSyncPlaceTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WhatAWindowRemembers_GoesOverTheSettingsAsTheyAreOnDiskNow_AndNotOverWhatAnotherWindowSaved()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
string file = Path.Combine(_root, "settings.json");
|
||||
var account = Guid.NewGuid();
|
||||
|
||||
// Two windows read the same settings.
|
||||
new AppSettings { Theme = "Light" }.Save(directory: _root);
|
||||
var first = AppSettings.Load(directory: _root);
|
||||
var second = AppSettings.Load(directory: _root);
|
||||
|
||||
// The second saves its Settings dialog, connects Quick Sync, and closes.
|
||||
second.Theme = "Dark";
|
||||
second.DefaultGiteaServer = Server;
|
||||
Assert.True(second.Save(directory: _root));
|
||||
second.RememberQuickSync(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: true));
|
||||
second.LastWorkspace = StartWorkspace.QuickSync;
|
||||
Assert.True(second.SaveWhatWasRemembered(directory: _root));
|
||||
|
||||
// The first, which read the settings before all that, closes on an account's page.
|
||||
first.LastWorkspace = account;
|
||||
Assert.True(first.SaveWhatWasRemembered(directory: _root));
|
||||
|
||||
var read = AppSettings.Load(directory: _root);
|
||||
Assert.Equal(account, read.LastWorkspace); // the window that closed last
|
||||
Assert.Equal("Dark", read.Theme); // not put back to what the first window had read
|
||||
Assert.Equal(Server, read.DefaultGiteaServer);
|
||||
Assert.Equal(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: true), read.RememberedQuickSync());
|
||||
|
||||
// A connection the first window makes itself is written.
|
||||
first.RememberQuickSync(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", @"C:\work", CreateOrgSubfolder: false));
|
||||
Assert.True(first.SaveWhatWasRemembered(directory: _root));
|
||||
read = AppSettings.Load(directory: _root);
|
||||
Assert.Equal(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", @"C:\work", CreateOrgSubfolder: false), read.RememberedQuickSync());
|
||||
Assert.Equal("Dark", read.Theme);
|
||||
|
||||
// A file that cannot be read any more is left for the user to mend, and it is said.
|
||||
File.WriteAllText(file, "{ this is where the file went wrong");
|
||||
var log = new RecordingActivityLog();
|
||||
Assert.False(first.SaveWhatWasRemembered(log, _root));
|
||||
Assert.Contains("went wrong", File.ReadAllText(file), StringComparison.Ordinal);
|
||||
Assert.Contains("What the app remembers was not written: the settings file cannot be read just now.", log.Messages, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- which workspace the app opens on
|
||||
|
||||
[Fact]
|
||||
@@ -1041,7 +1152,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,42 @@ 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!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ATokenTypedBehindTheMask_IsATokenTyped_AndTheMaskIsNoPartOfIt()
|
||||
{
|
||||
var saved = new SettingsTokenState(hasSavedToken: true, dependentAccounts: ["Work"]);
|
||||
|
||||
Assert.Equal(SettingsTokenAction.Store, saved.Plan(SettingsTokenState.Mask + "ghp_typed"));
|
||||
Assert.Equal(
|
||||
"The default token will be replaced on Save. The 1 account that uses it switches to the new token too.",
|
||||
saved.Caption(SettingsTokenState.Mask + "ghp_typed"));
|
||||
|
||||
// A part of the mask deleted and nothing typed: no token was entered. The saved one is removed, and it says so.
|
||||
Assert.Equal(SettingsTokenAction.Remove, saved.Plan(SettingsTokenState.Mask[1..]));
|
||||
Assert.True(saved.RemovalBreaksAccounts(SettingsTokenState.Mask[1..]));
|
||||
Assert.Throws<ArgumentNullException>(() => saved.Plan(null!));
|
||||
}
|
||||
}
|
||||
@@ -2658,15 +2658,15 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
foreach(WorkspaceViewModel vm in new[] { prefilled, typed, empty })
|
||||
{
|
||||
vm.OnDefaultTokenChanged(previous: "default-token-old-123", current: "default-token-new-456");
|
||||
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "default-token-old-123", current: "default-token-new-456");
|
||||
}
|
||||
|
||||
Assert.Equal("default-token-new-456", prefilled.Token);
|
||||
Assert.Equal("typed-by-hand-0000000", typed.Token); // somebody's own token stays
|
||||
Assert.Equal("", empty.Token); // the box was cleared by hand: it stays cleared
|
||||
|
||||
prefilled.OnDefaultTokenChanged(previous: "default-token-new-456", current: null);
|
||||
empty.OnDefaultTokenChanged(previous: null, current: "default-token-first-1"); // first saved: nothing was there before
|
||||
prefilled.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "default-token-new-456", current: null);
|
||||
empty.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: "default-token-first-1"); // first saved: nothing was there before
|
||||
prefilled.RefreshAccountToken(); // Quick Sync has no source to read from: nothing happens
|
||||
|
||||
Assert.Equal("", prefilled.Token);
|
||||
@@ -2684,7 +2684,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
vault.Store(AppSettings.DefaultTokenVaultId, "default-token-new-456");
|
||||
|
||||
// What the caller passes is not what an account uses: its source is the vault.
|
||||
vm.OnDefaultTokenChanged(previous: "something else entirely", current: "also something else");
|
||||
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "something else entirely", current: "also something else");
|
||||
|
||||
Assert.Equal("default-token-new-456", vm.Token);
|
||||
}
|
||||
@@ -2904,7 +2904,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
vault.Fails = true;
|
||||
vm.RefreshAccountToken(); // must not throw: the shell and Sync all call this
|
||||
vm.OnDefaultTokenChanged(previous: null, current: null);
|
||||
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: null, current: null);
|
||||
|
||||
Assert.Equal("default-token-old-123", vm.Token);
|
||||
Assert.Contains(log.Messages, m => m.Contains("its saved token could not be read", StringComparison.Ordinal));
|
||||
@@ -2934,7 +2934,7 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
public async Task QuickSync_WhoseTokenBoxWasEmptied_FailsTheSyncInItsOwnWords()
|
||||
{
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
vm.OnDefaultTokenChanged(previous: "token-1234567890", current: null); // the pre-filled default was removed
|
||||
vm.OnDefaultTokenChanged(ForgeEndpoint.GitHub, previous: "token-1234567890", current: null); // the pre-filled default was removed
|
||||
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
|
||||
|
||||
@@ -95,6 +95,46 @@ public sealed partial class SmokeTests : UiTestBase
|
||||
|
||||
|
||||
|
||||
// (4b) Settings holds a default token per forge (#212): GitHub, and a Gitea
|
||||
// server with its address. A token for a server whose address cannot be
|
||||
// used is not saved: the dialog says why and does not offer Save, and with
|
||||
// the address corrected it does. Nothing is saved here: the dialog is canceled.
|
||||
[Fact]
|
||||
public void Settings_HasADefaultTokenPerForge_AndDoesNotSaveATokenForAnAddressItRefuses()
|
||||
{
|
||||
Session.InvokeMenuItem("File", "MenuSettings");
|
||||
try
|
||||
{
|
||||
Session.WaitForText("Default tokens");
|
||||
Assert.NotNull(Session.WaitForElement("SettingsDefaultTokenBox")); // GitHub
|
||||
Session.EnterText(Session.WaitForElement("SettingsGiteaServerBox"), "http://git.example.test");
|
||||
Session.EnterText(Session.WaitForElement("SettingsGiteaTokenBox"), "a-token-that-is-not-saved");
|
||||
|
||||
Session.WaitForText("The server address cannot be used: it has to begin with https://.");
|
||||
Session.WaitFor(
|
||||
() => Session.FindInApp(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Button).And(cf.ByName("Save")).And(cf.ByClassName("Button")))
|
||||
is { IsEnabled: false } save ? save : null,
|
||||
"Save to be withheld");
|
||||
|
||||
Session.EnterText(Session.WaitForElement("SettingsGiteaServerBox"), "git.example.test");
|
||||
|
||||
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,
|
||||
"Save to be offered again");
|
||||
|
||||
Session.ClickButton("Cancel");
|
||||
Session.WaitForTextGone("Default tokens");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Session.TryClickButton("Cancel"); // no-op when the happy path closed it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Help -> Share gclo by email. Under the fixture the invitation is shown in the
|
||||
// app instead of being handed to an email program, so the test can read it and
|
||||
// nothing opens on the machine that runs the suite.
|
||||
@@ -327,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
|
||||
|
||||
@@ -38,25 +38,24 @@ public sealed class AccountTokenResolver
|
||||
public string? TryResolve(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
if(account.TokenSource == TokenSource.Default && account.Forge != ForgeKind.GitHub)
|
||||
if(account.TokenSource == TokenSource.Default)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The default token is a GitHub token. It is never handed to an account //
|
||||
// on another forge, however such an account came to say it uses it. //
|
||||
// The default token of the place the account is on, and of no other: it //
|
||||
// is looked up by that place. An account whose place cannot be used has //
|
||||
// none. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
return null;
|
||||
return account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? DefaultTokens.For(_vault, place) : null;
|
||||
}
|
||||
|
||||
string? token = account.TokenSource == TokenSource.Default
|
||||
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
|
||||
: _vault.TryRetrieve(account.TokenEntryId);
|
||||
string? token = _vault.TryRetrieve(account.TokenEntryId);
|
||||
return string.IsNullOrEmpty(token) ? null : token;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the vault holds a default token.</summary>
|
||||
public bool HasDefaultToken => !string.IsNullOrEmpty(_vault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
/// <summary>True when the vault holds a default token for <paramref name="place"/>.</summary>
|
||||
public bool HasDefaultTokenFor(ForgeEndpoint place) => DefaultTokens.For(_vault, place) is not null;
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +72,7 @@ public sealed class AccountTokenResolver
|
||||
{
|
||||
return account.Forge == ForgeKind.GitHub
|
||||
? "uses the default token, but no default token is saved (desktop app: Settings)"
|
||||
: "is set to use the default token, which is a GitHub token and is not used for another forge; give the account a token of its own";
|
||||
: $"uses the default token for {account.DescribePlace()}, but none is saved for it (desktop app: Settings)";
|
||||
}
|
||||
return $"has no token in {_vault.DescribeEntry(account.TokenEntryId)}";
|
||||
}
|
||||
|
||||
@@ -57,11 +57,13 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
private readonly Func<string?>? _storedToken;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Token"/> (#102).
|
||||
/// Fetches the default token saved in Settings for a place on demand (null, or
|
||||
/// returning null, when none is saved for it). Read when step 2 validates or the
|
||||
/// account is saved with "Use the default token"; never copied into
|
||||
/// <see cref="Token"/> (#102). Always asked for the place the account is on: a
|
||||
/// default token is not used for another place.
|
||||
/// </summary>
|
||||
private readonly Func<string?>? _defaultToken;
|
||||
private readonly Func<ForgeEndpoint, string?>? _defaultTokenOf;
|
||||
|
||||
|
||||
|
||||
@@ -70,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,
|
||||
@@ -80,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, 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);
|
||||
|
||||
@@ -118,8 +124,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
Func<ForgeEndpoint, IOrganizationLister> listerOf,
|
||||
AppSettings defaults,
|
||||
IActivityLog? log = null,
|
||||
Func<string?>? defaultToken = null)
|
||||
: this(store, ListerOfGitHub(listerOf), listerOf, existing: null, storedToken: null, defaultToken, log)
|
||||
Func<ForgeEndpoint, string?>? defaultTokenOf = null)
|
||||
: this(store, ListerOfGitHub(listerOf), listerOf, existing: null, storedToken: null, defaultTokenOf, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(defaults);
|
||||
TargetRoot = defaults.DefaultTargetFolder;
|
||||
@@ -137,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, 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 = "";
|
||||
@@ -147,12 +154,12 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// The account is where the connection was: its token was used with that place.
|
||||
_chosen = seed.Place ?? ForgeEndpoint.GitHub;
|
||||
|
||||
// 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. The default token is a GitHub token.
|
||||
UseDefaultToken = HasDefaultToken
|
||||
&& _chosen.Kind == ForgeKind.GitHub
|
||||
&& string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal);
|
||||
// A Quick Sync connection that runs on the default token of its place becomes
|
||||
// a default-token account; any other token is the account's own. Compared in
|
||||
// memory only; neither value is logged.
|
||||
string ofThePlace = DefaultTokenNow();
|
||||
HasDefaultToken = ofThePlace.Length > 0;
|
||||
UseDefaultToken = HasDefaultToken && string.Equals(seed.Token.Trim(), ofThePlace, StringComparison.Ordinal);
|
||||
Organization = seed.Organization;
|
||||
TargetRoot = seed.TargetRoot;
|
||||
CreateOrgSubfolder = seed.CreateOrgSubfolder;
|
||||
@@ -170,9 +177,20 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 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?>? OfGitHubOnly(Func<string?>? defaultToken)
|
||||
=> defaultToken is null ? null : place => place.Server is null ? defaultToken() : null;
|
||||
|
||||
|
||||
|
||||
private AccountWizardViewModel(
|
||||
AccountsStore store, IOrganizationLister orgLister, Func<ForgeEndpoint, IOrganizationLister>? listerOf,
|
||||
Account? existing, Func<string?>? storedToken, Func<string?>? defaultToken, IActivityLog? log)
|
||||
Account? existing, Func<string?>? storedToken, Func<ForgeEndpoint, string?>? defaultTokenOf, IActivityLog? log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(orgLister);
|
||||
@@ -183,11 +201,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
_existing = existing;
|
||||
_existingPlace = existing is not null && existing.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place : null;
|
||||
_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 && existing.Forge == ForgeKind.GitHub;
|
||||
_defaultTokenOf = defaultTokenOf;
|
||||
// Decided when the place is: the choice is offered only when a default token
|
||||
// exists for it then, and the value itself is fetched again when a step needs it.
|
||||
HasDefaultToken = DefaultTokenNow().Length > 0;
|
||||
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
|
||||
|
||||
Step = 1;
|
||||
NameError = "";
|
||||
@@ -231,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;
|
||||
|
||||
@@ -334,10 +352,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
public partial bool IsValidatingToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// True when a default token was saved in Settings for the place the account is
|
||||
/// on, when the wizard opened or when step 1 took the place: that is when step 2
|
||||
/// offers the choice between it and a token of the account's own.
|
||||
/// </summary>
|
||||
public bool HasDefaultToken { get; }
|
||||
public bool HasDefaultToken { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Step 2's choice: true means the account resolves to the default token from
|
||||
@@ -360,11 +379,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// default token while none is saved, which otherwise could not be given another.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Never for an account that is not on GitHub: the default token is a GitHub
|
||||
/// token, and such an account has a token of its own.
|
||||
/// The default token is the one of the place the account is on. A place that has
|
||||
/// none offers no choice: the account has a token of its own.
|
||||
/// </remarks>
|
||||
public bool OffersTokenChoice
|
||||
=> PlaceKind == ForgeKind.GitHub && (HasDefaultToken || _existing?.UsesDefaultToken == true);
|
||||
public bool OffersTokenChoice => HasDefaultToken || _existing?.UsesDefaultToken == true;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
|
||||
@@ -389,7 +407,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
return "No default token is saved right now. Save one in Settings, or give this account a token of its own.";
|
||||
}
|
||||
|
||||
int others = _store.CountUsingDefaultToken() - (_existing?.UsesDefaultToken == true ? 1 : 0);
|
||||
int others = (Place is { } place ? _store.UsingDefaultTokenOf(place).Count : 0) - (_existing?.UsesDefaultToken == true ? 1 : 0);
|
||||
string shared = others switch
|
||||
{
|
||||
0 => "Used by Quick Sync.",
|
||||
@@ -444,6 +462,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The default token saved for the place the account is on, as it is right now;
|
||||
/// empty when there is none, and for an account whose place cannot be used.
|
||||
/// </summary>
|
||||
private string DefaultTokenNow() => Place is { } place ? _defaultTokenOf?.Invoke(place)?.Trim() ?? "" : "";
|
||||
|
||||
|
||||
|
||||
/// <summary>The kind of forge the account is on: the stored one for an edit, the chosen one for a new account.</summary>
|
||||
private ForgeKind PlaceKind => _existing?.Forge ?? _chosen.Kind;
|
||||
|
||||
@@ -523,22 +549,12 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// An edit with the box left empty validates the STORED token, fetched
|
||||
// here and used for this one call only.
|
||||
string candidate;
|
||||
if(UseDefaultToken && PlaceKind != ForgeKind.GitHub)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// The choice is not shown for such an account. Whatever set it, //
|
||||
// the default token is a GitHub token and is not sent anywhere //
|
||||
// else. //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
TokenError = "The default token is a GitHub token. This account needs a token of its own.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(UseDefaultToken)
|
||||
{
|
||||
// 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() ?? "";
|
||||
// The default token of the place the account is on, and of no
|
||||
// other, fetched for this one call only; the box stays untouched
|
||||
// (and disabled) so it is never shown or retained.
|
||||
candidate = DefaultTokenNow();
|
||||
if(candidate.Length == 0)
|
||||
{
|
||||
TokenError = "The default token is no longer saved. Save one in Settings, "
|
||||
@@ -653,7 +669,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// Another place than the one the wizard was on. What was entered for the //
|
||||
// other place is not carried over: a token typed for one server is not //
|
||||
// sent to another, and an organization of one is not one of the other. The //
|
||||
// default token is a GitHub token, so it is not the choice anywhere else. //
|
||||
// default token on offer is the one saved for the new place, if any. //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
_orgLister = listerOf(place); // first: if there is no lister for the place, the wizard stays on the one it has
|
||||
_chosen = place;
|
||||
@@ -661,7 +677,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
TokenError = "";
|
||||
Organizations.Clear();
|
||||
Organization = "";
|
||||
UseDefaultToken = place.Kind == ForgeKind.GitHub && HasDefaultToken;
|
||||
HasDefaultToken = DefaultTokenNow().Length > 0;
|
||||
UseDefaultToken = HasDefaultToken;
|
||||
OnPropertyChanged(nameof(HasDefaultToken));
|
||||
OnPropertyChanged(nameof(DefaultTokenCaption));
|
||||
OnPropertyChanged(nameof(PlaceName));
|
||||
OnPropertyChanged(nameof(OffersTokenChoice));
|
||||
OnPropertyChanged(nameof(TokenHeader));
|
||||
|
||||
@@ -349,12 +349,18 @@ public sealed class AccountsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>How many accounts resolve to the default token (Settings shows what a removal breaks).</summary>
|
||||
public int CountUsingDefaultToken()
|
||||
/// <summary>
|
||||
/// The accounts that resolve to the default token of <paramref name="place"/>
|
||||
/// (Settings shows what a removal breaks, the wizard how many share it).
|
||||
/// </summary>
|
||||
public IReadOnlyList<Account> UsingDefaultTokenOf(ForgeEndpoint place)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(place);
|
||||
lock(_gate)
|
||||
{
|
||||
return _accounts.Count(a => a.UsesDefaultToken);
|
||||
return _accounts
|
||||
.Where(a => a.UsesDefaultToken && a.TryGetEndpoint(out ForgeEndpoint? where, out _) && where.Equals(place))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,8 +610,6 @@ public sealed class AccountsStore
|
||||
/// A token that was saved for one server is not sent to another because somebody
|
||||
/// edited the address; the account is deleted and added again, with its token
|
||||
/// entered for the new place.</item>
|
||||
/// <item>The default token is a GitHub token: an account on another forge has a
|
||||
/// token of its own.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private Account WithItsForgeChecked(Account account)
|
||||
@@ -636,13 +640,6 @@ public sealed class AccountsStore
|
||||
+ "Delete the account and add it again for the other one.",
|
||||
nameof(account));
|
||||
}
|
||||
|
||||
if(account.UsesDefaultToken && account.Forge != ForgeKind.GitHub)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Account '{account.Name}' is on {endpoint.DisplayName}; the default token is a GitHub token, so the account needs a token of its own.",
|
||||
nameof(account));
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ public sealed class AppSettings
|
||||
/// </summary>
|
||||
public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11");
|
||||
|
||||
/// <summary>
|
||||
/// The address of the Gitea server a default token is saved for (Settings); null
|
||||
/// when there is none. The token itself is in the vault, under an entry named
|
||||
/// after this address (<see cref="DefaultTokens.EntryFor"/>), never in this file.
|
||||
/// </summary>
|
||||
public string? DefaultGiteaServer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The workspace that was open last: an account's id, or <see cref="StartWorkspace.QuickSync"/>.
|
||||
/// Null when nothing is recorded yet. See <see cref="StartWorkspace.Choose"/>.
|
||||
@@ -132,14 +139,45 @@ public sealed class AppSettings
|
||||
|
||||
|
||||
|
||||
/// <summary>True once this window recorded a Quick Sync connection itself (<see cref="RememberQuickSync"/>).</summary>
|
||||
private bool _rememberedAConnection;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes the settings because the app remembered something by itself (the
|
||||
/// workspace that was open, Quick Sync's last connection), not because the user
|
||||
/// saved them. Does nothing when the file could not be read at start: that file
|
||||
/// is not written over with defaults on the app's own account.
|
||||
/// Writes what the app remembered by itself (the workspace that was open, and
|
||||
/// Quick Sync's connection if this window made one), not what the user saved in
|
||||
/// Settings. Only those values are written, over the file as it is on disk now:
|
||||
/// another gclo window may have saved settings since this one read them, and a
|
||||
/// window that closes does not put its older copy of them back. Does nothing
|
||||
/// when the file could not be read, at start or now: that file is not written
|
||||
/// over with defaults on the app's own account.
|
||||
/// </summary>
|
||||
public bool SaveWhatWasRemembered(IActivityLog? log = null, string? directory = null)
|
||||
=> !CouldNotBeRead && Save(log, directory);
|
||||
{
|
||||
if(CouldNotBeRead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AppSettings onDisk = Load(log: null, directory);
|
||||
if(onDisk.CouldNotBeRead)
|
||||
{
|
||||
log?.Info("What the app remembers was not written: the settings file cannot be read just now.");
|
||||
return false;
|
||||
}
|
||||
|
||||
onDisk.LastWorkspace = LastWorkspace;
|
||||
if(_rememberedAConnection)
|
||||
{
|
||||
onDisk.QuickSyncForge = QuickSyncForge;
|
||||
onDisk.QuickSyncServer = QuickSyncServer;
|
||||
onDisk.QuickSyncOrganization = QuickSyncOrganization;
|
||||
onDisk.QuickSyncTargetFolder = QuickSyncTargetFolder;
|
||||
onDisk.QuickSyncCreateOrgSubfolder = QuickSyncCreateOrgSubfolder;
|
||||
}
|
||||
return onDisk.Save(log, directory);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +258,15 @@ public sealed class AppSettings
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The Gitea server a default token is saved for, or null when there is none or
|
||||
/// what is stored is no address that can be used (a file edited by hand).
|
||||
/// </summary>
|
||||
public ForgeEndpoint? DefaultGiteaPlace()
|
||||
=> ForgeEndpoint.TryCreate(ForgeKind.Gitea, DefaultGiteaServer, out ForgeEndpoint? place, out _) ? place : null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Records a Quick Sync connection that worked, so the card opens the same way
|
||||
/// next time. The caller saves. Never a token.
|
||||
@@ -232,6 +279,7 @@ public sealed class AppSettings
|
||||
QuickSyncOrganization = connection.Organization.Trim();
|
||||
QuickSyncTargetFolder = connection.TargetFolder.Trim();
|
||||
QuickSyncCreateOrgSubfolder = connection.CreateOrgSubfolder;
|
||||
_rememberedAConnection = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
@@ -10,26 +12,42 @@ namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The default token saved in Settings for a place, which Quick Sync fills in when
|
||||
/// it is on that place. A default token belongs to one place and is never offered
|
||||
/// for another.
|
||||
/// The default tokens saved in Settings: one for GitHub, and one for a Gitea
|
||||
/// server. Quick Sync fills in the one of the place it is on, and an account that
|
||||
/// uses "the default token" gets the one of the place it is on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A default token belongs to one place and is never used for another. That is not
|
||||
/// a check somebody has to remember to make: the vault entry is named after the
|
||||
/// place (for a Gitea server, after its address), so asking for the default token
|
||||
/// of a place finds the one that was saved for exactly that place, or none.
|
||||
/// </remarks>
|
||||
public static class DefaultTokens
|
||||
{
|
||||
/// <summary>
|
||||
/// The default token for <paramref name="place"/>, or null when none is saved for
|
||||
/// it. Today Settings holds one for GitHub only.
|
||||
/// The vault entry that holds the default token of <paramref name="place"/>:
|
||||
/// for GitHub the one it always was; for a Gitea server one made from the
|
||||
/// server's address. Never to be changed: saved tokens are found by it.
|
||||
/// </summary>
|
||||
public static Guid EntryFor(ForgeEndpoint place)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(place);
|
||||
if(place.Server is null)
|
||||
{
|
||||
return AppSettings.DefaultTokenVaultId;
|
||||
}
|
||||
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes($"gclo default token\n{place.Kind}\n{place.Server.AbsoluteUri}"));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The default token saved for <paramref name="place"/>, or null when none is saved for it.</summary>
|
||||
public static string? For(ITokenVault vault, ForgeEndpoint place)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
ArgumentNullException.ThrowIfNull(place);
|
||||
if(place.Kind != ForgeKind.GitHub)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? token = vault.TryRetrieve(AppSettings.DefaultTokenVaultId);
|
||||
string? token = vault.TryRetrieve(EntryFor(place));
|
||||
return string.IsNullOrEmpty(token) ? null : token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What Save does with the Gitea group of the Settings dialog
|
||||
/// (<see cref="SettingsGiteaState.Plan"/>): keep, store or remove the default
|
||||
/// token, for which server, or the reason it cannot be saved as it stands.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <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;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The Gitea group of the Settings dialog, as state the dialog only renders: the
|
||||
/// address of the server and its default token. It is the counterpart of
|
||||
/// <see cref="SettingsTokenState"/> (the GitHub token), with the one thing a Gitea
|
||||
/// default has on top: the token is for one server, so the address and the token
|
||||
/// 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. There is one
|
||||
/// default Gitea server: when another one is saved, taking a server up removes
|
||||
/// that one's token, as typing a token for another server does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SettingsGiteaState
|
||||
{
|
||||
private readonly ForgeEndpoint? _savedPlace;
|
||||
|
||||
private readonly SettingsTokenState _token;
|
||||
|
||||
private readonly Func<ForgeEndpoint, bool> _holdsATokenFor;
|
||||
|
||||
/// <summary>What <see cref="_holdsATokenFor"/> answered, by server: the vault is asked once for each.</summary>
|
||||
private readonly Dictionary<ForgeEndpoint, bool> _held = [];
|
||||
|
||||
|
||||
|
||||
/// <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;
|
||||
/// <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, 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, dependentAccounts);
|
||||
_holdsATokenFor = holdsATokenFor ?? (_ => false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when a default token is saved for a server.</summary>
|
||||
public bool HasSavedToken => _token.HasSavedToken;
|
||||
|
||||
/// <summary>What the address box shows at first: the saved server's address, or nothing.</summary>
|
||||
public string InitialServer => HasSavedToken ? _savedPlace?.Server?.AbsoluteUri ?? "" : "";
|
||||
|
||||
/// <summary>What the token box shows at first: the mask when a token is saved, empty otherwise.</summary>
|
||||
public string InitialBoxValue => _token.InitialBoxValue;
|
||||
|
||||
/// <summary>True when "Remove saved token" applies: a token is saved and the box still shows the mask.</summary>
|
||||
public bool CanOfferRemoval(string boxValue) => _token.CanOfferRemoval(boxValue);
|
||||
|
||||
|
||||
|
||||
/// <summary>What Save will do with the address box and the token box as they stand.</summary>
|
||||
public SettingsGiteaPlan Plan(string server, string boxValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
boxValue = SettingsTokenState.WithoutTheMask(boxValue);
|
||||
bool typed = boxValue.Length > 0 && !string.Equals(boxValue, SettingsTokenState.Mask, StringComparison.Ordinal);
|
||||
|
||||
if(string.IsNullOrWhiteSpace(server))
|
||||
{
|
||||
if(typed)
|
||||
{
|
||||
return Refused("Enter the address of the server this token is for.");
|
||||
}
|
||||
|
||||
// No address is no default: what was saved goes with it.
|
||||
return HasSavedToken ? new SettingsGiteaPlan(SettingsTokenAction.Remove, _savedPlace, "") : Nothing;
|
||||
}
|
||||
|
||||
if(!ForgeEndpoint.TryCreate(ForgeKind.Gitea, server, out ForgeEndpoint? place, out string? reason))
|
||||
{
|
||||
return Refused(char.ToUpperInvariant(reason[0]) + reason[1..] + ".");
|
||||
}
|
||||
|
||||
if(!HasSavedToken)
|
||||
{
|
||||
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))
|
||||
{
|
||||
SettingsTokenAction action = _token.Plan(boxValue);
|
||||
return action == SettingsTokenAction.Keep ? Nothing : new SettingsGiteaPlan(action, place, "");
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Another server than the one the token was saved for. The saved token is //
|
||||
// not sent there: the token of the new server has to be entered, unless the //
|
||||
// vault holds a default token for that very server already (see the //
|
||||
// remarks), which is then taken up in place of the saved one. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if(typed)
|
||||
{
|
||||
return new SettingsGiteaPlan(SettingsTokenAction.Store, place, "");
|
||||
}
|
||||
|
||||
return HoldsATokenFor(place)
|
||||
? new SettingsGiteaPlan(SettingsTokenAction.Keep, place, "", TakesUp: true)
|
||||
: Refused($"The saved token is for {Address(_savedPlace!)}. Enter the token for {Address(place)}, or put the address back.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether the vault holds a default token for <paramref name="place"/>. Asked
|
||||
/// once for each server, however often the boxes change: the plan is made again
|
||||
/// for every character that is typed.
|
||||
/// </summary>
|
||||
private bool HoldsATokenFor(ForgeEndpoint place)
|
||||
{
|
||||
if(!_held.TryGetValue(place, out bool held))
|
||||
{
|
||||
held = _holdsATokenFor(place);
|
||||
_held[place] = held;
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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)
|
||||
{
|
||||
SettingsGiteaPlan plan = Plan(server, boxValue);
|
||||
if(plan.IsError)
|
||||
{
|
||||
return plan.Error;
|
||||
}
|
||||
|
||||
switch(plan.Action)
|
||||
{
|
||||
case SettingsTokenAction.Store when !HasSavedToken:
|
||||
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 {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 && HasSavedToken:
|
||||
return $"A default token is already saved for {Address(plan.Place!)}. Save makes this server the Gitea default again, "
|
||||
+ $"and removes the one for {Address(_savedPlace!)}; open Settings afterwards to replace or remove the token."
|
||||
+ _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 {Address(_savedPlace!)}" + _token.UsedBy() + ". Type to replace it; clear the box to remove it."
|
||||
: "No default token saved for a Gitea server.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when the caption is a warning: the group cannot be saved as it stands, or
|
||||
/// Save would take a token away that accounts depend on.
|
||||
/// </summary>
|
||||
public bool IsWarning(string server, string boxValue)
|
||||
{
|
||||
SettingsGiteaPlan plan = Plan(server, boxValue);
|
||||
bool takesTheSavedTokenAway = plan.Action == SettingsTokenAction.Remove
|
||||
|| ((plan.Action == SettingsTokenAction.Store || plan.TakesUp) && HasSavedToken && !plan.Place!.Equals(_savedPlace));
|
||||
return plan.IsError || (takesTheSavedTokenAway && _token.DependentCount > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Does what <paramref name="plan"/> says: stores or removes the token in
|
||||
/// <paramref name="vault"/> and records the server in <paramref name="settings"/>
|
||||
/// (the caller saves them). The token of a server that is replaced by another is
|
||||
/// removed. Returns what changed, for the log, without the token; null when
|
||||
/// nothing did.
|
||||
/// </summary>
|
||||
public string? Apply(SettingsGiteaPlan plan, string boxValue, ITokenVault vault, AppSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(boxValue);
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
if(plan.IsError)
|
||||
{
|
||||
throw new InvalidOperationException("The Gitea default cannot be saved as it stands: " + plan.Error);
|
||||
}
|
||||
|
||||
switch(plan.Action)
|
||||
{
|
||||
case SettingsTokenAction.Store:
|
||||
// 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!), SettingsTokenState.WithoutTheMask(boxValue));
|
||||
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
|
||||
if(HasSavedToken && !plan.Place.Equals(_savedPlace))
|
||||
{
|
||||
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
|
||||
}
|
||||
return HasSavedToken && plan.Place.Equals(_savedPlace)
|
||||
? $"default token for {plan.Place.DisplayName} replaced"
|
||||
: $"default token for {plan.Place.DisplayName} stored";
|
||||
case SettingsTokenAction.Remove:
|
||||
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
|
||||
settings.DefaultGiteaServer = null;
|
||||
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:
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Looked for again, now: the plan was made when the address was typed, //
|
||||
// and the token may have been removed since (another window, Windows' //
|
||||
// Credential Manager). Without it there is nothing to take up, and the //
|
||||
// default that is saved is not given up for it. //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(DefaultTokens.For(vault, plan.Place!) is null)
|
||||
{
|
||||
return $"default token for {plan.Place!.DisplayName} not taken up: it is no longer saved; nothing was changed";
|
||||
}
|
||||
|
||||
settings.DefaultGiteaServer = plan.Place!.Server!.AbsoluteUri;
|
||||
if(!HasSavedToken)
|
||||
{
|
||||
return $"default token for {plan.Place.DisplayName} taken up again (it was already saved)";
|
||||
}
|
||||
|
||||
// One default Gitea server: the one it takes the place of goes, as when a token is typed for another server.
|
||||
vault.Delete(DefaultTokens.EntryFor(_savedPlace!));
|
||||
return $"default token for {plan.Place.DisplayName} taken up again (it was already saved), and the one for {_savedPlace!.DisplayName} removed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static SettingsGiteaPlan Nothing => new(SettingsTokenAction.Keep, null, "");
|
||||
|
||||
|
||||
|
||||
private static SettingsGiteaPlan Refused(string why) => new(SettingsTokenAction.Keep, null, why);
|
||||
}
|
||||
@@ -36,6 +36,24 @@ 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.
|
||||
/// The box itself is left as it is (rewriting it while it is typed into moves the
|
||||
/// caret): this is applied wherever its value is read, and to what is stored.
|
||||
/// </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; }
|
||||
|
||||
@@ -53,7 +71,7 @@ public sealed class SettingsTokenState
|
||||
/// <summary>What Save will do with <paramref name="boxValue"/>, the box's current content.</summary>
|
||||
public SettingsTokenAction Plan(string boxValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(boxValue);
|
||||
boxValue = WithoutTheMask(boxValue);
|
||||
if(string.Equals(boxValue, Mask, StringComparison.Ordinal))
|
||||
{
|
||||
return HasSavedToken ? SettingsTokenAction.Keep : SettingsTokenAction.Store;
|
||||
@@ -93,7 +111,7 @@ public sealed class SettingsTokenState
|
||||
|
||||
|
||||
|
||||
private string UsedBy() => DependentCount switch
|
||||
internal string UsedBy() => DependentCount switch
|
||||
{
|
||||
0 => " and used by Quick Sync",
|
||||
1 => " and used by Quick Sync and 1 account",
|
||||
@@ -102,7 +120,7 @@ public sealed class SettingsTokenState
|
||||
|
||||
|
||||
|
||||
private string RotationNote() => DependentCount switch
|
||||
internal string RotationNote() => DependentCount switch
|
||||
{
|
||||
0 => "",
|
||||
1 => " The 1 account that uses it switches to the new token too.",
|
||||
@@ -111,7 +129,7 @@ public sealed class SettingsTokenState
|
||||
|
||||
|
||||
|
||||
private string RemovalWarning()
|
||||
internal string RemovalWarning()
|
||||
{
|
||||
if(DependentCount == 0)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1024,17 +1024,19 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
/// Sync follows only when its box still holds what Settings put there
|
||||
/// (<paramref name="previous"/>, or nothing): a token somebody typed stays.
|
||||
/// </summary>
|
||||
/// <param name="place">The place whose default token changed.</param>
|
||||
/// <param name="previous">The default token before the change; null when there was none.</param>
|
||||
/// <param name="current">The default token now; null when there is none.</param>
|
||||
public void OnDefaultTokenChanged(string? previous, string? current)
|
||||
public void OnDefaultTokenChanged(ForgeEndpoint place, string? previous, string? current)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(place);
|
||||
if(_account is not null)
|
||||
{
|
||||
RefreshAccountToken();
|
||||
}
|
||||
else if(_placeIsUsable && _quickSyncPlace.Kind == ForgeKind.GitHub && string.Equals(Token, previous ?? "", StringComparison.Ordinal))
|
||||
else if(_placeIsUsable && _quickSyncPlace.Equals(place) && string.Equals(Token, previous ?? "", StringComparison.Ordinal))
|
||||
{
|
||||
// The default token is a GitHub token: a Quick Sync that is on another place is not given it.
|
||||
// The default token of one place: a Quick Sync that is on another place is not given it.
|
||||
SetTokenFromItsSource(current ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+24
-12
@@ -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();
|
||||
@@ -809,7 +807,6 @@ public sealed partial class MainWindow : Window
|
||||
/// </summary>
|
||||
private async Task ShowAccountWizardAsync(Account? existing)
|
||||
{
|
||||
Func<string?> defaultToken = () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
|
||||
AccountWizardViewModel viewModel;
|
||||
if(existing is null)
|
||||
{
|
||||
@@ -820,7 +817,7 @@ public sealed partial class MainWindow : Window
|
||||
place => UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place),
|
||||
_settings,
|
||||
_log,
|
||||
defaultToken);
|
||||
place => DefaultTokens.For(_tokenVault, place));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -834,7 +831,7 @@ public sealed partial class MainWindow : Window
|
||||
existing,
|
||||
() => _tokenVault.TryRetrieve(existing.TokenEntryId),
|
||||
_log,
|
||||
defaultToken);
|
||||
defaultTokenOf: place => DefaultTokens.For(_tokenVault, place));
|
||||
}
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
@@ -888,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: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
defaultTokenOf: where => DefaultTokens.For(_tokenVault, where));
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
@@ -1301,13 +1298,28 @@ public sealed partial class MainWindow : Window
|
||||
if(await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
|
||||
{
|
||||
// Held only to tell the open workspaces what changed; never logged or stored.
|
||||
string? defaultTokenBefore = _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
|
||||
// A default token belongs to one place: GitHub's, and the one of the Gitea
|
||||
// server that is named in Settings before the save and after it.
|
||||
var places = new List<ForgeEndpoint> { ForgeEndpoint.GitHub };
|
||||
if(_settings.DefaultGiteaPlace() is { } giteaBefore)
|
||||
{
|
||||
places.Add(giteaBefore);
|
||||
}
|
||||
var before = places.ToDictionary(place => place, place => DefaultTokens.For(_tokenVault, place));
|
||||
dialog.ApplyAndSave(); // logs what changed
|
||||
ApplySettings(seedQuickSyncConcurrency: _settings.DefaultMaxConcurrency != concurrencyBefore);
|
||||
string? defaultTokenNow = _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId);
|
||||
foreach(WorkspaceEntry workspace in _workspaces.Values)
|
||||
if(_settings.DefaultGiteaPlace() is { } giteaNow && !places.Contains(giteaNow))
|
||||
{
|
||||
workspace.ViewModel.OnDefaultTokenChanged(defaultTokenBefore, defaultTokenNow);
|
||||
places.Add(giteaNow);
|
||||
}
|
||||
foreach(ForgeEndpoint place in places)
|
||||
{
|
||||
string? was = before.GetValueOrDefault(place);
|
||||
string? now = DefaultTokens.For(_tokenVault, place);
|
||||
foreach(WorkspaceEntry workspace in _workspaces.Values)
|
||||
{
|
||||
workspace.ViewModel.OnDefaultTokenChanged(place, was, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -30,12 +30,22 @@
|
||||
ToolTipService.ToolTip="Choose the default target folder"
|
||||
Click="BrowseButton_Click" />
|
||||
</Grid>
|
||||
<!-- Default tokens (#212): one for GitHub, one for a Gitea server, named as
|
||||
everywhere else. Each belongs to its place and is used for no other. -->
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Style="{ThemeResource BodyStrongTextBlockStyle}" Text="Default tokens" />
|
||||
<TextBlock
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Quick Sync fills in the default token of the place it is on, and an account can use the one of its place. Stored in Windows Credential Manager, never in a file." />
|
||||
</StackPanel>
|
||||
<PasswordBox
|
||||
x:Name="DefaultTokenBox"
|
||||
Header="Default GitHub token"
|
||||
Header="GitHub"
|
||||
AutomationProperties.AutomationId="SettingsDefaultTokenBox"
|
||||
PlaceholderText="ghp_…"
|
||||
ToolTipService.ToolTip="Pre-fills Quick Sync and serves every account set to use it. Stored in Windows Credential Manager, never in a file."
|
||||
ToolTipService.ToolTip="The default token for GitHub: pre-fills Quick Sync when it is on GitHub and serves every GitHub account set to use it."
|
||||
PasswordChanged="DefaultTokenBox_PasswordChanged" />
|
||||
<!-- Caption takes the width, the Remove link keeps its own: a horizontal
|
||||
StackPanel clipped the link once the caption grew (#101). -->
|
||||
@@ -61,6 +71,45 @@
|
||||
ToolTipService.ToolTip="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
|
||||
Click="RemoveTokenButton_Click" />
|
||||
</Grid>
|
||||
<!-- Gitea: the token is for one server, so the address and the token are
|
||||
saved, replaced and removed together. -->
|
||||
<TextBox
|
||||
x:Name="GiteaServerBox"
|
||||
Header="Gitea"
|
||||
AutomationProperties.AutomationId="SettingsGiteaServerBox"
|
||||
PlaceholderText="Server address, like https://git.example.com"
|
||||
ToolTipService.ToolTip="The address of the Gitea (or Forgejo) server the default token below is for. HTTPS only; the token is used for this server and no other."
|
||||
TextChanged="GiteaServerBox_TextChanged" />
|
||||
<PasswordBox
|
||||
x:Name="GiteaTokenBox"
|
||||
AutomationProperties.AutomationId="SettingsGiteaTokenBox"
|
||||
AutomationProperties.Name="Default token for the Gitea server"
|
||||
PlaceholderText="Access token of that server"
|
||||
ToolTipService.ToolTip="The default token for the Gitea server above: pre-fills Quick Sync when it is on that server and serves every account on it that is set to use the default token."
|
||||
PasswordChanged="GiteaTokenBox_PasswordChanged" />
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
x:Name="GiteaStateText"
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
AutomationProperties.AutomationId="SettingsGiteaStateText"
|
||||
AutomationProperties.LiveSetting="Polite"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<HyperlinkButton
|
||||
x:Name="RemoveGiteaTokenButton"
|
||||
Grid.Column="1"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Remove saved token"
|
||||
ToolTipService.ToolTip="Clears the box; the saved default token is deleted from Windows Credential Manager when you press Save"
|
||||
Click="RemoveGiteaTokenButton_Click" />
|
||||
</Grid>
|
||||
<NumberBox
|
||||
x:Name="ConcurrencyBox"
|
||||
Header="Default parallelism"
|
||||
|
||||
@@ -50,6 +50,13 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
/// </summary>
|
||||
private readonly SettingsTokenState _tokenState;
|
||||
|
||||
/// <summary>
|
||||
/// The same for the Gitea group (#212): the address of the server and its default
|
||||
/// token, which are saved, replaced and removed together. The saved token is never
|
||||
/// carried to another address.
|
||||
/// </summary>
|
||||
private readonly SettingsGiteaState _giteaState;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -64,8 +71,14 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
ArgumentNullException.ThrowIfNull(accounts);
|
||||
_tokenState = new SettingsTokenState(
|
||||
hasSavedToken: !string.IsNullOrEmpty(vault.TryRetrieve(AppSettings.DefaultTokenVaultId)),
|
||||
dependentAccounts: accounts.GetAll().Where(a => a.UsesDefaultToken).Select(a => a.Name).ToList());
|
||||
hasSavedToken: DefaultTokens.For(vault, ForgeEndpoint.GitHub) is not null,
|
||||
dependentAccounts: accounts.UsingDefaultTokenOf(ForgeEndpoint.GitHub).Select(a => a.Name).ToList());
|
||||
ForgeEndpoint? giteaPlace = settings.DefaultGiteaPlace();
|
||||
_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(),
|
||||
holdsATokenFor: place => HoldsADefaultToken(vault, place));
|
||||
ArgumentNullException.ThrowIfNull(windowHandleProvider);
|
||||
_settings = settings;
|
||||
_vault = vault;
|
||||
@@ -89,6 +102,9 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
// removes it. The caption follows every edit (PasswordChanged).
|
||||
DefaultTokenBox.Password = _tokenState.InitialBoxValue;
|
||||
UpdateTokenCaption();
|
||||
GiteaServerBox.Text = _giteaState.InitialServer;
|
||||
GiteaTokenBox.Password = _giteaState.InitialBoxValue;
|
||||
UpdateGiteaCaption();
|
||||
}
|
||||
|
||||
|
||||
@@ -154,13 +170,22 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
}
|
||||
}
|
||||
|
||||
// The Gitea default: the token to the vault, the address of its server to the
|
||||
// settings, which are written just below. Save is not offered while the group
|
||||
// cannot be saved as it stands (UpdateGiteaCaption).
|
||||
string giteaToken = GiteaTokenBox.Password;
|
||||
if(_giteaState.Apply(_giteaState.Plan(GiteaServerBox.Text, giteaToken), giteaToken, _vault, _settings) is { } giteaChange)
|
||||
{
|
||||
changes.Add(giteaChange);
|
||||
}
|
||||
|
||||
bool written = _settings.Save(_log);
|
||||
|
||||
string typed = DefaultTokenBox.Password;
|
||||
switch(_tokenState.Plan(typed))
|
||||
{
|
||||
case SettingsTokenAction.Store:
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, SettingsTokenState.WithoutTheMask(typed));
|
||||
changes.Add(_tokenState.HasSavedToken ? "default token replaced" : "default token stored");
|
||||
break;
|
||||
case SettingsTokenAction.Remove:
|
||||
@@ -188,11 +213,67 @@ public sealed partial class SettingsDialog : ContentDialog
|
||||
|
||||
|
||||
|
||||
// A box that shows the mask and is typed into without being emptied first holds
|
||||
// the mask and then the typing. The box is left as it is; the state objects take
|
||||
// the mask out where they read it, and it is taken out of what is stored.
|
||||
private void DefaultTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
|
||||
=> UpdateTokenCaption();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether the vault holds a default token for a Gitea server, asked while its
|
||||
/// address is typed. A credential store that cannot be read just then holds none
|
||||
/// as far as the caption goes: nothing is lost by that, and typing is not broken.
|
||||
/// </summary>
|
||||
private static bool HoldsADefaultToken(ITokenVault vault, ForgeEndpoint place)
|
||||
{
|
||||
try
|
||||
{
|
||||
return DefaultTokens.For(vault, place) is not null;
|
||||
}
|
||||
catch(System.ComponentModel.Win32Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void RemoveGiteaTokenButton_Click(object sender, RoutedEventArgs e)
|
||||
=> GiteaTokenBox.Password = "";
|
||||
|
||||
|
||||
|
||||
private void GiteaTokenBox_PasswordChanged(object sender, RoutedEventArgs e)
|
||||
=> UpdateGiteaCaption();
|
||||
|
||||
|
||||
|
||||
private void GiteaServerBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
=> UpdateGiteaCaption();
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The caption of the Gitea group follows its two boxes: the state object words
|
||||
/// what Save will do, or why the group cannot be saved as it stands. In that case
|
||||
/// Save is not offered, so nothing is half saved; a warning (that, or a removal
|
||||
/// that breaks accounts) is shown in the critical colour.
|
||||
/// </summary>
|
||||
private void UpdateGiteaCaption()
|
||||
{
|
||||
string server = GiteaServerBox.Text;
|
||||
string box = GiteaTokenBox.Password;
|
||||
GiteaStateText.Text = _giteaState.Caption(server, box);
|
||||
GiteaStateText.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[
|
||||
_giteaState.IsWarning(server, box) ? "SystemFillColorCriticalBrush" : "TextFillColorSecondaryBrush"];
|
||||
RemoveGiteaTokenButton.Visibility = _giteaState.CanOfferRemoval(box) ? Visibility.Visible : Visibility.Collapsed;
|
||||
IsPrimaryButtonEnabled = !_giteaState.Plan(server, box).IsError;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The caption and the Remove link follow the box: the state object words the
|
||||
/// consequence of Save (keep, replace, remove), and a removal that breaks accounts
|
||||
|
||||
Reference in new issue
Block a user