Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9667f437b7
|
||
|
|
c0a99cacd8
|
||
|
|
ef86a52bc6
|
No files matched your search
+25
-9
@@ -244,23 +244,26 @@ the app's account wizard, and use them from either.
|
||||
### `gclo accounts` / `gclo accounts list`
|
||||
|
||||
Lists the saved accounts, one per line, in aligned columns: name, organization,
|
||||
target root, and last sync time (local time, `never` when the account has not
|
||||
completed a sync yet):
|
||||
target root, token source (`own` or `default`), and last sync time (local time,
|
||||
`never` when the account has not completed a sync yet):
|
||||
|
||||
```
|
||||
work contoso C:\src\contoso 2026-07-04 09:12
|
||||
personal octocat D:\mirror never
|
||||
work contoso C:\src\contoso own 2026-07-04 09:12
|
||||
personal octocat D:\mirror default never
|
||||
```
|
||||
|
||||
With `--json` it prints a single-line JSON array with everything a script needs
|
||||
to reason about a run; `lastSync` is the UTC timestamp of the last completed
|
||||
sync and `lastSyncSummary` its summary line, both `null` until the first sync:
|
||||
sync and `lastSyncSummary` its summary line, both `null` until the first sync;
|
||||
`tokenSource` is `own` or `default`:
|
||||
|
||||
```json
|
||||
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44."}]
|
||||
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}]
|
||||
```
|
||||
|
||||
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`).
|
||||
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`)
|
||||
for an `own` account; a `default` account has no entry of its own and resolves
|
||||
to the default token saved in the desktop app's Settings whenever it syncs.
|
||||
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
|
||||
hint goes to stderr; the exit code is still 0.
|
||||
|
||||
@@ -277,6 +280,16 @@ variable) and is written to Windows Credential Manager — never put it on the
|
||||
command line. A name that another account already uses (case-insensitive)
|
||||
exits with code 2.
|
||||
|
||||
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.
|
||||
|
||||
```powershell
|
||||
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
|
||||
```
|
||||
|
||||
### `gclo accounts edit`
|
||||
|
||||
```powershell
|
||||
@@ -286,8 +299,11 @@ gclo accounts edit --name work --rename "work (contoso)"
|
||||
```
|
||||
|
||||
`--name` picks the account; every other option replaces just that setting.
|
||||
Without a token option the stored token is left untouched. Editing with
|
||||
nothing to change is a usage error.
|
||||
Without a token option the token (and its source) is left untouched. A
|
||||
`--token-*` option gives the account a token of its own (also switching a
|
||||
`default` account to `own`); `--use-default-token` switches it to the default
|
||||
token and removes its own entry from Credential Manager. Editing with nothing
|
||||
to change is a usage error.
|
||||
|
||||
### `gclo accounts remove`
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# UX plan: the default token and account tokens
|
||||
|
||||
> **Status: approved** by the owner on 2026-10-10; step 1 is #104. Delivers #101
|
||||
> (Settings shows a saved default token as a masked value) and #102 (Add account
|
||||
> uses the default token by reference). Tracker of record: GitHub Issues; this
|
||||
> document is the design.
|
||||
|
||||
## The problem, as the owner sees it
|
||||
|
||||
1. In **Settings**, a saved default token leaves the token box empty. An empty box
|
||||
reads as "nothing saved", even with the caption underneath.
|
||||
2. **Add account** always demands a token on step 2, even when a default token is
|
||||
saved. Every account then holds its own copy, so rotating the default token in
|
||||
Settings does nothing for existing accounts: each keeps the old copy until it
|
||||
fails.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- **An account references the default token; it never copies it.** A new field
|
||||
`TokenSource` on the account record: `Default` (use whatever the default token
|
||||
is right now) or `Own` (this account's own vault entry, today's behaviour).
|
||||
Rotating the default token in Settings rotates every `Default` account at once.
|
||||
`Own` accounts are untouched by the default, as today.
|
||||
- **One resolver, used everywhere.** `AccountTokenResolver.Resolve(account)` in
|
||||
`gclo.ViewModels` returns the token to use for an account (`Default` → vault
|
||||
entry `DefaultTokenVaultId`; `Own` → vault entry `account.Id`) plus *why* it is
|
||||
missing when it is. The desktop workspace, Sync All, the wizard's validation
|
||||
and the CLI's `sync --account` all go through it. No consumer reads the vault
|
||||
for an account directly any more.
|
||||
- **The token itself is never shown.** The Settings box shows a fixed mask when a
|
||||
token is saved; the wizard never loads a token into a box; the reveal button
|
||||
of a `PasswordBox` can only ever reveal what the user typed in this session.
|
||||
- **Backward compatible on disk.** `TokenSource` defaults to `Own` when absent, so
|
||||
every existing `accounts.json` and every existing vault entry keeps working.
|
||||
1.0.x builds reading a newer file ignore the field (System.Text.Json default).
|
||||
- **Deleting the default token is a guarded action** when `Default` accounts
|
||||
exist: Settings says which accounts stop working and asks for confirmation.
|
||||
|
||||
## Settings dialog (#101)
|
||||
|
||||
| State | Token box | Caption | Remove link |
|
||||
| --- | --- | --- | --- |
|
||||
| No default token saved | empty, placeholder `ghp_…` | "No default token saved. Quick Sync will ask for one each launch." | hidden |
|
||||
| Default token saved, untouched | shows the mask `••••••••••••••••` (a constant, never the token) | "A default token is saved and used by Quick Sync and *N* account(s). Type to replace it." | visible |
|
||||
| User typed a new value | the typed value (masked) | "The default token will be replaced on Save." | visible |
|
||||
| User cleared the box, or pressed Remove | empty | "The default token will be removed on Save. *N* account(s) use it and will stop syncing until a new one is saved." (plain "…will be removed on Save." when N = 0) | hidden |
|
||||
|
||||
Save semantics: mask unchanged → keep; non-empty and not the mask → store; empty
|
||||
→ remove (only if one was saved). Removing while `Default` accounts exist shows a
|
||||
confirmation ("Remove the default token? These accounts use it: A, B.") before the
|
||||
dialog closes; Cancel returns to the dialog with the mask restored.
|
||||
|
||||
Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the
|
||||
mask is announced as the field's value by the password pattern (dots), the
|
||||
caption is a live region, and the Remove link's text names the consequence.
|
||||
|
||||
## Add account / Edit account wizard (#102)
|
||||
|
||||
Step 2 ("Access token") becomes a choice, shown only when a default token is
|
||||
saved; without one the step looks exactly as today.
|
||||
|
||||
```text
|
||||
Step 2 of 4: Access token
|
||||
|
||||
( ) Use the default token from Settings ← selected by default for a new account
|
||||
Used by Quick Sync and N other account(s). Replacing it in Settings updates
|
||||
this account too.
|
||||
( ) Use a different token for this account
|
||||
[ Personal access token ..................... ] (enabled only when selected)
|
||||
How to create a token
|
||||
|
||||
Next validates the chosen token and lists the organizations it can see.
|
||||
```
|
||||
|
||||
- **New account:** "Use the default token" is preselected. Next validates the
|
||||
*default* token through the resolver and lists its organizations. On save the
|
||||
account is written with `TokenSource = Default` and **no vault entry** of its
|
||||
own. Choosing "a different token" requires typing one; save writes
|
||||
`TokenSource = Own` and the entry, as today.
|
||||
- **Edit account:** the radio reflects the current source. Switching `Own →
|
||||
Default` deletes the account's own vault entry on save (confirmed in the
|
||||
caption: "This account's own token will be removed from Credential Manager").
|
||||
Switching `Default → Own` requires a token. Staying on `Own` with the box
|
||||
empty keeps the stored token, as today.
|
||||
- **Default token missing** (removed after the account was created): step 2
|
||||
shows "The default token is no longer saved. Save one in Settings, or use a
|
||||
different token for this account." and Next stays put.
|
||||
- **Seeded wizard** ("save this Quick Sync connection as an account"): if the
|
||||
typed Quick Sync token equals the saved default token, the wizard preselects
|
||||
"Use the default token"; otherwise "a different token" with the typed value.
|
||||
(Comparison happens in memory in the view model; nothing is logged.)
|
||||
|
||||
## Where the account's token shows up elsewhere
|
||||
|
||||
- **Navigation pane / account header:** `Default` accounts get a small
|
||||
"Default token" tag (text, not just an icon) in the account's header, so the
|
||||
dependency on Settings is visible where the account is used.
|
||||
- **Account workspace:** the token box is prefilled from the resolver as today.
|
||||
For a `Default` account its header reads "Using the default token"; editing the
|
||||
box affects this session only, exactly as for Quick Sync.
|
||||
- **Sync All:** a `Default` account whose default token is missing is skipped
|
||||
with the result "No default token saved (Settings)" instead of a generic auth
|
||||
error.
|
||||
- **Error messages:** every "no token" message names the source
|
||||
("the default token in Settings" or "this account's own token") and the fix.
|
||||
|
||||
## CLI parity (`gclo accounts`, `gclo sync --account`)
|
||||
|
||||
- `accounts add … --use-default-token` creates a `Default` account; the existing
|
||||
`--token-env | --token-file | --token-stdin` create `Own` accounts. Exactly one
|
||||
of the four is required (today: one of the three).
|
||||
- `accounts edit --name X --use-default-token` switches to `Default` and removes
|
||||
the own entry; a `--token-*` option switches to `Own`.
|
||||
- `accounts list` gains a `token` column: `default` or `own`; the JSON output
|
||||
gains `"tokenSource"`.
|
||||
- `sync --account X` resolves through the resolver; its "no token" error names
|
||||
the source and the fix (`gclo accounts edit --name X --token-stdin`, or saving
|
||||
a default token in the desktop app's Settings; the CLI gets no way to *set*
|
||||
the default token in this change).
|
||||
- The default token stays a desktop-app concept to set, but the CLI reads it,
|
||||
so a headless mirror can use `Default` accounts created on the same machine.
|
||||
|
||||
## Data model and storage
|
||||
|
||||
- `Account.TokenSource` (`enum TokenSource { Own, Default }`), serialized as a
|
||||
string, default `Own`, `[JsonIgnoreCondition.WhenWritingDefault]` so existing
|
||||
files stay byte-identical until an account actually uses the default.
|
||||
- `AccountsStore.Save(account, token)` keeps its contract; a `Default` account is
|
||||
always saved with `token: null`, and switching to `Default` removes the own
|
||||
entry inside the same vault-first/compensate sequence the store already has.
|
||||
- `AccountsStore.CountUsingDefaultToken()` feeds the Settings captions.
|
||||
- Vault scoping (#60) is unchanged: `DefaultTokenVaultId` already lives in the
|
||||
same scope as the accounts.
|
||||
|
||||
## Security review (threat model T5, T6)
|
||||
|
||||
- No new place holds a token: the mask is a constant; the resolver returns the
|
||||
token to the same callers that already received it; nothing is logged.
|
||||
- A `Default` account widens the blast radius of the default token to every
|
||||
such account by design; the Settings warning on removal and the "Default
|
||||
token" tag make that dependency visible. Rotation becomes a single action,
|
||||
which is the point.
|
||||
- The CLI still has no `--token <value>`; `--use-default-token` carries no
|
||||
secret on the command line.
|
||||
|
||||
## Tests
|
||||
|
||||
- ViewModels (100% line coverage stays enforced): resolver (both sources,
|
||||
missing cases), wizard step 2 for new/edit/seeded in both sources and the
|
||||
missing-default error, store save/switch/compensation with `TokenSource`,
|
||||
settings captions and remove-with-dependents counts (through a small
|
||||
`SettingsTokenState` view model so the dialog has no logic of its own),
|
||||
Sync All skip reason.
|
||||
- CLI (100%): `--use-default-token` on add/edit, mutual exclusion with
|
||||
`--token-*`, list column and JSON field, sync resolution error text.
|
||||
- FlaUI: Settings shows the mask when a token is saved and an empty box when
|
||||
not; Add account shows the radio only when a default token exists, and the
|
||||
token box is disabled while "Use the default token" is selected.
|
||||
- Serialization round-trip: an `accounts.json` without the field loads as `Own`
|
||||
and re-saves without the field.
|
||||
|
||||
## Delivery
|
||||
|
||||
One issue per layer, one PR each, in this order, every PR green on all gates:
|
||||
|
||||
1. **#102a – model + resolver + store + CLI** (no UI change yet; `Default`
|
||||
accounts can only be created by the CLI so far). Threat model updated.
|
||||
2. **#102b – wizard + pane tag + Sync All reason** (desktop).
|
||||
3. **#101 – Settings dialog** (mask, captions, guarded removal), last because its
|
||||
"used by N accounts" count depends on step 1.
|
||||
|
||||
Ships in the next minor (1.1.0): it adds a field to the account record and a
|
||||
CLI option, which is a feature, not a fix; 1.0.2 stays the pipeline/data-root
|
||||
fix release.
|
||||
@@ -87,7 +87,7 @@ flowchart LR
|
||||
| T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated |
|
||||
| T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths` | Mitigated |
|
||||
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees | Mitigated |
|
||||
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
|
||||
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; accounts may reference the default token (`TokenSource`), resolved at use time through one resolver and never copied; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
|
||||
| T6 | Credential Manager | Information disclosure | Another process as the same user reads the stored token | Medium | High | Documented boundary (SECURITY.md); vault scoped per data directory so test or portable instances never see the default profile's tokens | Accepted (OS boundary) |
|
||||
| T7 | GitHub API | Denial of service | Rate limits or abuse detection stop a sync | Medium | Low | Secondary-rate-limit and abuse handling with backoff; per-repo failure isolation; cancellation | Mitigated |
|
||||
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated |
|
||||
@@ -114,3 +114,4 @@ flowchart LR
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T11: the packaging PR is opened with a short-lived release GitHub App token (contents + pull requests on this repository, no bypass) instead of a long-lived PAT; the commit is GitHub-signed (#85) |
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | Local data directory moved to `%LOCALAPPDATA%\KofTwentyTwo\gclo`: the Velopack install root is deleted by Setup.exe, which destroyed accounts.json and settings.json (#91) |
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) |
|
||||
| 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) |
|
||||
@@ -234,6 +234,144 @@ public sealed class AccountsCommandTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
[Fact]
|
||||
public void Add_UseDefaultToken_CreatesAnAccountWithoutAnOwnEntry()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(
|
||||
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Account saved = Store.FindByName("shared")!;
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
Assert.Contains("uses the default token", console.Out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_UseDefaultToken_WithoutASavedDefault_IsAFatalError()
|
||||
{
|
||||
CliErrorException ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog()));
|
||||
|
||||
Assert.Contains("No default token is saved", ex.Message);
|
||||
Assert.Contains("Settings", ex.Message);
|
||||
Assert.Empty(Store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_UseDefaultToken_WithAnotherTokenOption_IsAUsageError()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
using IDisposable _ = TokenEnv("ghp_x");
|
||||
|
||||
Assert.Throws<CliUsageException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token", "--token-env", EnvVar],
|
||||
Open, new NullLog()));
|
||||
Assert.Empty(Store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_UseDefaultToken_SwitchesTheAccount_AndRemovesItsOwnToken()
|
||||
{
|
||||
Seed("work", "acme", @"C:\a");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Guid id = Store.FindByName("work")!.Id;
|
||||
Assert.Equal("tok-work", _vault.TryRetrieve(id));
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.True(Store.FindByName("work")!.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(id));
|
||||
Assert.Contains("now uses the default token", console.Out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_UseDefaultToken_WithoutASavedDefault_IsAFatalError_AndKeepsTheOwnToken()
|
||||
{
|
||||
Seed("work", "acme", @"C:\a");
|
||||
Guid id = Store.FindByName("work")!.Id;
|
||||
|
||||
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog()));
|
||||
|
||||
Assert.False(Store.FindByName("work")!.UsesDefaultToken);
|
||||
Assert.Equal("tok-work", _vault.TryRetrieve(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_TokenOption_SwitchesADefaultTokenAccountToItsOwnToken()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
|
||||
using IDisposable _ = TokenEnv("ghp_mine");
|
||||
|
||||
int code = AccountsCommand.Run(["edit", "--name", "shared", "--token-env", EnvVar], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Account updated = Store.FindByName("shared")!;
|
||||
Assert.False(updated.UsesDefaultToken);
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(updated.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_OtherSettings_LeaveTheTokenSourceAlone()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
AccountsCommand.Run(["edit", "--name", "shared", "--parallel", "3"], Open, new NullLog());
|
||||
|
||||
Account updated = Store.FindByName("shared")!;
|
||||
Assert.True(updated.UsesDefaultToken);
|
||||
Assert.Equal(3, updated.MaxConcurrency);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void List_ShowsTheTokenSource_InColumnsAndJson()
|
||||
{
|
||||
Seed("work", "acme", @"C:\a");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\b", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
using(var console = new ConsoleCapture())
|
||||
{
|
||||
Assert.Equal(0, AccountsCommand.Run([], Open, new NullLog()));
|
||||
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Contains(lines, l => l.StartsWith("shared", StringComparison.Ordinal) && l.Contains(" default ", StringComparison.Ordinal));
|
||||
Assert.Contains(lines, l => l.StartsWith("work", StringComparison.Ordinal) && l.Contains(" own ", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
using(var console = new ConsoleCapture())
|
||||
{
|
||||
Assert.Equal(0, AccountsCommand.Run(["--json"], Open, new NullLog()));
|
||||
Assert.Contains("\"name\":\"shared\"", console.Out);
|
||||
Assert.Contains("\"tokenSource\":\"default\"", console.Out);
|
||||
Assert.Contains("\"tokenSource\":\"own\"", console.Out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_UnknownOption_Throws()
|
||||
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
|
||||
|
||||
@@ -91,12 +91,16 @@ internal sealed class FakeRepoLister : IRepositoryLister
|
||||
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
/// <summary>The token of the most recent call, so a test can check which source was used.</summary>
|
||||
public string? LastToken { get; private set; }
|
||||
|
||||
|
||||
|
||||
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
LastToken = token;
|
||||
return Throw is not null
|
||||
? Task.FromException<IReadOnlyList<RepoDescriptor>>(Throw)
|
||||
: Task.FromResult(Result);
|
||||
|
||||
@@ -579,6 +579,40 @@ public sealed class SyncCommandTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_UsingTheDefaultToken_SyncsWithIt()
|
||||
{
|
||||
Account account = Seed("shared", "acme");
|
||||
_vault.Delete(account.Id);
|
||||
Store.Save(account with { TokenSource = TokenSource.Default }, null);
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--account", "shared");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal("ghp_default", lister.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_UsingTheDefaultToken_WithoutOne_NamesSettingsInTheError()
|
||||
{
|
||||
Account account = Seed("shared", "acme");
|
||||
_vault.Delete(account.Id);
|
||||
Store.Save(account with { TokenSource = TokenSource.Default }, null);
|
||||
|
||||
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--account", "shared"));
|
||||
|
||||
Assert.Contains("no default token is saved", ex.Message);
|
||||
Assert.Contains("Settings", ex.Message);
|
||||
Assert.DoesNotContain("gclo:account:", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_ExplicitTokenOption_OverridesVault()
|
||||
{
|
||||
|
||||
@@ -26,6 +26,37 @@ public sealed class TokenOptionsTests
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void UseDefaultToken_IsAnExplicitSource_ThatResolvesToNoValue()
|
||||
{
|
||||
TokenOptions options = Parse("--use-default-token");
|
||||
|
||||
Assert.True(options.HasExplicitSource);
|
||||
Assert.True(options.UseDefault);
|
||||
CliUsageException ex = Assert.Throws<CliUsageException>(() => options.Resolve());
|
||||
Assert.Contains("--use-default-token", ex.Message);
|
||||
Assert.Contains("accounts add", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void UseDefaultToken_CombinedWithAnotherSource_IsAUsageError()
|
||||
{
|
||||
CliUsageException ex = Assert.Throws<CliUsageException>(() => Parse("--use-default-token", "--token-stdin").Resolve());
|
||||
|
||||
Assert.Contains("only one of", ex.Message);
|
||||
Assert.Contains("--use-default-token", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void UseDefaultToken_RejectsAValue()
|
||||
=> Assert.Throws<CliUsageException>(() => Parse("--use-default-token=yes"));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TryConsume_NonTokenOption_ReturnsFalse()
|
||||
{
|
||||
|
||||
@@ -21,4 +21,5 @@ internal sealed record AccountSummary(
|
||||
bool CreateOrgSubfolder,
|
||||
int MaxConcurrency,
|
||||
DateTimeOffset? LastSync,
|
||||
string? LastSyncSummary);
|
||||
string? LastSyncSummary,
|
||||
string TokenSource);
|
||||
+62
-14
@@ -32,12 +32,14 @@ internal static class AccountsCommand
|
||||
|
||||
list (default)
|
||||
Prints the accounts, one per line, in aligned columns: name,
|
||||
organization, target root, and last sync time (local time, 'never'
|
||||
when the account has not completed a sync yet).
|
||||
organization, target root, token source ('own' or 'default'), and last
|
||||
sync time (local time, 'never' when the account has not completed a
|
||||
sync yet).
|
||||
--json Print a single-line JSON array instead:
|
||||
[{"id":"...","name":"...","description":"...","organization":"...",
|
||||
"targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N,
|
||||
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null}]
|
||||
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null,
|
||||
"tokenSource":"own"|"default"}]
|
||||
|
||||
add
|
||||
--name <name> Display name; must be unique (case-insensitive).
|
||||
@@ -49,6 +51,10 @@ internal static class AccountsCommand
|
||||
--token-env <VAR> | --token-file <path> | --token-stdin
|
||||
The token to store (default: the GITHUB_TOKEN
|
||||
environment variable). Never pass it as an argument.
|
||||
--use-default-token Instead of a token of its own, the account uses
|
||||
the default token saved in the desktop app's
|
||||
Settings, read whenever it syncs, so replacing
|
||||
the default token there updates this account too.
|
||||
|
||||
edit
|
||||
--name <name> The account to change (required).
|
||||
@@ -58,8 +64,11 @@ internal static class AccountsCommand
|
||||
--org-subfolder | --no-org-subfolder
|
||||
Turn the organization subfolder on or off.
|
||||
--token-env | --token-file | --token-stdin
|
||||
Replace the stored token; without a token option
|
||||
the stored token is left untouched.
|
||||
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.
|
||||
|
||||
remove
|
||||
--name <name> Deletes the account's settings and its stored
|
||||
@@ -137,7 +146,7 @@ internal static class AccountsCommand
|
||||
IReadOnlyList<AccountSummary> summaries = accounts
|
||||
.Select(a => new AccountSummary(
|
||||
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary))
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a)))
|
||||
.ToList();
|
||||
Console.Out.WriteLine(JsonSerializer.Serialize(
|
||||
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
|
||||
@@ -158,7 +167,7 @@ internal static class AccountsCommand
|
||||
{
|
||||
Console.Out.WriteLine(
|
||||
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
|
||||
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
|
||||
+ $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}");
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
@@ -234,11 +243,14 @@ internal static class AccountsCommand
|
||||
throw new CliUsageException("--target is required.");
|
||||
}
|
||||
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'");
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}");
|
||||
try
|
||||
{
|
||||
string token = tokenOptions.Resolve();
|
||||
(AccountsStore store, _) = open(log);
|
||||
// The token (if the account gets one of its own) is read before the store
|
||||
// opens, so a bad token option fails without touching accounts.json.
|
||||
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve();
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -248,9 +260,10 @@ internal static class AccountsCommand
|
||||
TargetRoot = target.Trim(),
|
||||
CreateOrgSubfolder = orgSubfolder,
|
||||
MaxConcurrency = parallel,
|
||||
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
|
||||
};
|
||||
SaveOrFail(store, account, token);
|
||||
Console.Out.WriteLine($"Account '{account.Name}' added.");
|
||||
Console.Out.WriteLine($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex)
|
||||
@@ -332,10 +345,17 @@ internal static class AccountsCommand
|
||||
log.Info($"accounts edit started: name='{name.Trim()}'");
|
||||
try
|
||||
{
|
||||
(AccountsStore store, _) = open(log);
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
|
||||
string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null;
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve() : null;
|
||||
|
||||
// A token option decides the source: --use-default-token switches to the
|
||||
// default token (and drops the own entry); --token-* gives the account a
|
||||
// token of its own; neither leaves the source and the token untouched.
|
||||
TokenSource source = tokenOptions.UseDefault
|
||||
? TokenSource.Default
|
||||
: token is null ? existing.TokenSource : TokenSource.Own;
|
||||
Account updated = existing with
|
||||
{
|
||||
Name = rename?.Trim() ?? existing.Name,
|
||||
@@ -344,9 +364,13 @@ internal static class AccountsCommand
|
||||
Description = description?.Trim() ?? existing.Description,
|
||||
MaxConcurrency = parallel ?? existing.MaxConcurrency,
|
||||
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
|
||||
TokenSource = source,
|
||||
};
|
||||
SaveOrFail(store, updated, token);
|
||||
Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced)."));
|
||||
string tokenNote = tokenOptions.UseDefault
|
||||
? " (now uses the default token)."
|
||||
: token is null ? "." : " (token replaced).";
|
||||
Console.Out.WriteLine($"Account '{updated.Name}' updated" + tokenNote);
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex)
|
||||
@@ -405,6 +429,30 @@ internal static class AccountsCommand
|
||||
|
||||
// ---------------------------------------------------------------- shared
|
||||
|
||||
/// <summary>'own' or 'default': the token source as the list column and the JSON field show it.</summary>
|
||||
private static string TokenSourceLabel(Account account) => account.UsesDefaultToken ? "default" : "own";
|
||||
|
||||
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault)
|
||||
{
|
||||
tokenOptions.EnsureAtMostOneSource();
|
||||
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultToken)
|
||||
{
|
||||
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, "
|
||||
+ "--token-file, or --token-stdin.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
|
||||
private static void SaveOrFail(AccountsStore store, Account account, string? token)
|
||||
{
|
||||
|
||||
@@ -468,14 +468,17 @@ internal static class SyncCommand
|
||||
}
|
||||
|
||||
// vault is non-null whenever account is: both come from AccountsCommand.Open.
|
||||
string? token = vault!.TryRetrieve(account.Id);
|
||||
if(string.IsNullOrEmpty(token))
|
||||
string? token = new AccountTokenResolver(vault!).TryResolve(account);
|
||||
if(token is null)
|
||||
{
|
||||
string fix = account.UsesDefaultToken
|
||||
? "Save a default token in the desktop app's Settings, switch the account to its own token with "
|
||||
+ "'gclo accounts edit --name <name> --token-stdin',"
|
||||
: "Re-enter the token with 'gclo accounts edit --name <name> --token-stdin' or in the desktop app's "
|
||||
+ "account wizard, restore the credential entry manually,";
|
||||
throw new CliErrorException(
|
||||
$"Account '{account.Name}' has no token in Windows Credential Manager "
|
||||
+ $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with "
|
||||
+ "'gclo accounts edit --name <name> --token-stdin' or in the desktop app's account wizard, "
|
||||
+ "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin.");
|
||||
$"Account '{account.Name}' {AccountTokenResolver.MissingDescription(account)}. {fix} "
|
||||
+ "or pass a token with --token-env, --token-file, or --token-stdin.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -25,14 +25,25 @@ internal sealed class TokenOptions
|
||||
|
||||
private bool _useStdin;
|
||||
|
||||
private bool _useDefault;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when any token option (--token-env, --token-file, --token-stdin) was
|
||||
/// given. 'gclo sync --account' uses this to let an explicit token source
|
||||
/// override the account's vault-stored token.
|
||||
/// True when any token option (--token-env, --token-file, --token-stdin,
|
||||
/// --use-default-token) was given. 'gclo sync --account' uses this to let an
|
||||
/// explicit token source override the account's stored token.
|
||||
/// </summary>
|
||||
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin;
|
||||
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin || _useDefault;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// --use-default-token: the account resolves to the default token saved in the
|
||||
/// desktop app's Settings instead of holding a token of its own (accounts add
|
||||
/// and edit only). <see cref="Resolve"/> rejects it, as it names no token value.
|
||||
/// </summary>
|
||||
public bool UseDefault => _useDefault;
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +62,10 @@ internal sealed class TokenOptions
|
||||
reader.RejectValue();
|
||||
_useStdin = true;
|
||||
return true;
|
||||
case "--use-default-token":
|
||||
reader.RejectValue();
|
||||
_useDefault = true;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -58,6 +73,18 @@ internal sealed class TokenOptions
|
||||
|
||||
|
||||
|
||||
/// <summary>Usage error when more than one token option was given; a no-op otherwise.</summary>
|
||||
public void EnsureAtMostOneSource()
|
||||
{
|
||||
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0) + (_useDefault ? 1 : 0);
|
||||
if(sources > 1)
|
||||
{
|
||||
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin, --use-default-token.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the token from the single configured source, or from the
|
||||
/// <see cref="DefaultVariable"/> environment variable when none was given.
|
||||
@@ -65,10 +92,11 @@ internal sealed class TokenOptions
|
||||
/// </summary>
|
||||
public string Resolve()
|
||||
{
|
||||
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0);
|
||||
if(sources > 1)
|
||||
EnsureAtMostOneSource();
|
||||
if(_useDefault)
|
||||
{
|
||||
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin.");
|
||||
throw new CliUsageException(
|
||||
"--use-default-token names no token value; it is for 'gclo accounts add' and 'gclo accounts edit' only.");
|
||||
}
|
||||
|
||||
if(_useStdin)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AccountTokenResolver"/>: an account's own entry for <see cref="TokenSource.Own"/>,
|
||||
/// the default token for <see cref="TokenSource.Default"/>, null (with a source-specific
|
||||
/// description) when the source holds nothing.
|
||||
/// </summary>
|
||||
public sealed class AccountTokenResolverTests
|
||||
{
|
||||
private readonly InMemoryVault _vault = new();
|
||||
|
||||
|
||||
|
||||
private static Account MakeAccount(TokenSource source) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "work",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos",
|
||||
TokenSource = source,
|
||||
};
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NullVault_Throws()
|
||||
=> Assert.Throws<ArgumentNullException>(() => new AccountTokenResolver(null!));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NullAccount_Throws()
|
||||
{
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => resolver.TryResolve(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => AccountTokenResolver.MissingDescription(null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void OwnAccount_ResolvesItsOwnEntry_NotTheDefault()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Own);
|
||||
_vault.Store(account.Id, "ghp_own");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.Equal("ghp_own", new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultAccount_ResolvesTheDefaultToken_EvenWhenAnOwnEntryLingers()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Default);
|
||||
_vault.Store(account.Id, "ghp_stale");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.Equal("ghp_default", new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultAccount_FollowsRotationOfTheDefaultToken()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Default);
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_first");
|
||||
Assert.Equal("ghp_first", resolver.TryResolve(account));
|
||||
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_rotated");
|
||||
|
||||
Assert.Equal("ghp_rotated", resolver.TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(TokenSource.Own)]
|
||||
[InlineData(TokenSource.Default)]
|
||||
public void MissingSource_ResolvesNull(TokenSource source)
|
||||
{
|
||||
Account account = MakeAccount(source);
|
||||
|
||||
Assert.Null(new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void EmptyStoredValue_CountsAsMissing()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Own);
|
||||
_vault.Store(account.Id, "");
|
||||
|
||||
Assert.Null(new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void HasDefaultToken_ReflectsTheVault()
|
||||
{
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
Assert.False(resolver.HasDefaultToken);
|
||||
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.True(resolver.HasDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void MissingDescription_NamesTheSource_AndNeverATokenValue()
|
||||
{
|
||||
Account own = MakeAccount(TokenSource.Own);
|
||||
Account byDefault = MakeAccount(TokenSource.Default);
|
||||
|
||||
string ownText = AccountTokenResolver.MissingDescription(own);
|
||||
string defaultText = AccountTokenResolver.MissingDescription(byDefault);
|
||||
|
||||
Assert.Contains($"gclo:account:{own.Id:N}", ownText);
|
||||
Assert.Contains("Settings", defaultText);
|
||||
Assert.DoesNotContain("ghp", ownText + defaultText);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,206 @@ public sealed class AccountWizardViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
private AccountWizardViewModel NewWizardWithDefault(string? defaultToken, Account? existing = null, string? existingToken = null)
|
||||
=> new(_store, _orgs, _defaults, existing, () => existingToken, defaultToken: () => defaultToken);
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NewWizard_WithoutADefaultToken_OffersNoChoice_AndUsesAnOwnToken()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault(defaultToken: null);
|
||||
|
||||
Assert.False(wizard.HasDefaultToken);
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.True(wizard.IsOwnTokenSelected);
|
||||
Assert.Equal(1, wizard.TokenChoiceIndex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NewWizard_WithADefaultToken_PreselectsIt()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
|
||||
Assert.True(wizard.HasDefaultToken);
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
Assert.False(wizard.IsOwnTokenSelected);
|
||||
Assert.Equal(0, wizard.TokenChoiceIndex);
|
||||
Assert.Equal("", wizard.Token); // the default token is never loaded into the box
|
||||
Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TokenChoiceIndex_RoundTripsToUseDefaultToken_AndNotifies()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
var changed = new List<string?>();
|
||||
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
|
||||
|
||||
wizard.TokenChoiceIndex = 1;
|
||||
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.IsOwnTokenSelected), StringComparison.Ordinal));
|
||||
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.TokenChoiceIndex), StringComparison.Ordinal));
|
||||
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.DefaultTokenCaption), StringComparison.Ordinal));
|
||||
|
||||
wizard.TokenChoiceIndex = 0;
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NewWizard_UsingTheDefaultToken_ValidatesWithIt_AndSavesWithoutAnOwnEntry()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
wizard.Name = "Shared";
|
||||
string? validated = null;
|
||||
_orgs.Handler = (token, _) =>
|
||||
{
|
||||
validated = token;
|
||||
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
|
||||
};
|
||||
|
||||
await AdvanceToStepAsync(wizard, 3);
|
||||
Assert.Equal("ghp_default", validated);
|
||||
Assert.Equal("", wizard.TokenError);
|
||||
wizard.Organization = "acme";
|
||||
wizard.TargetRoot = @"C:\repos";
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
Assert.Equal(1, _store.CountUsingDefaultToken());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NewWizard_UsingTheDefaultToken_WhenItWasRemovedMeanwhile_StaysOnStepTwo()
|
||||
{
|
||||
string? liveDefault = "ghp_default";
|
||||
var wizard = new AccountWizardViewModel(_store, _orgs, _defaults, defaultToken: () => liveDefault);
|
||||
wizard.Name = "Shared";
|
||||
await AdvanceToStepAsync(wizard, 2);
|
||||
liveDefault = null; // removed in Settings while the wizard is open
|
||||
|
||||
Assert.False(await wizard.TryAdvanceAsync());
|
||||
|
||||
Assert.Equal(2, wizard.Step);
|
||||
Assert.Contains("default token is no longer saved", wizard.TokenError);
|
||||
Assert.Contains("Settings", wizard.TokenError);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NewWizard_ChoosingAnOwnToken_IgnoresTheDefault_AndStoresTheTypedOne()
|
||||
{
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
|
||||
wizard.Name = "Mine";
|
||||
wizard.UseDefaultToken = false;
|
||||
wizard.Token = "ghp_mine";
|
||||
string? validated = null;
|
||||
_orgs.Handler = (token, _) =>
|
||||
{
|
||||
validated = token;
|
||||
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
|
||||
};
|
||||
|
||||
await AdvanceToStepAsync(wizard, 3);
|
||||
wizard.Organization = "acme";
|
||||
wizard.TargetRoot = @"C:\repos";
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Assert.Equal("ghp_mine", validated);
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.False(saved.UsesDefaultToken);
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task EditWizard_DefaultTokenAccount_StartsOnTheDefault_AndCanSwitchToAnOwnToken()
|
||||
{
|
||||
Account existing = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
|
||||
_store.Save(existing, null);
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing);
|
||||
Assert.True(wizard.UseDefaultToken);
|
||||
Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal); // itself excluded
|
||||
|
||||
wizard.UseDefaultToken = false;
|
||||
await AdvanceToStepAsync(wizard, 2);
|
||||
Assert.False(await wizard.TryAdvanceAsync()); // no own token typed yet
|
||||
Assert.Contains("instead of the default one", wizard.TokenError);
|
||||
|
||||
wizard.Token = "ghp_mine";
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
|
||||
Account saved = Assert.Single(_store.GetAll());
|
||||
Assert.False(saved.UsesDefaultToken);
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task EditWizard_OwnTokenAccount_SwitchingToTheDefault_RemovesItsOwnEntry_AndSaysSo()
|
||||
{
|
||||
Account existing = MakeAccount("Work");
|
||||
_store.Save(existing, "ghp_own");
|
||||
_store.Save(MakeAccount("Other") with { TokenSource = TokenSource.Default }, null);
|
||||
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing, "ghp_own");
|
||||
Assert.False(wizard.UseDefaultToken);
|
||||
|
||||
wizard.UseDefaultToken = true;
|
||||
|
||||
Assert.Contains("1 other account", wizard.DefaultTokenCaption);
|
||||
Assert.Contains("own token will be removed", wizard.DefaultTokenCaption);
|
||||
await AdvanceToStepAsync(wizard, 4);
|
||||
await wizard.SaveAsync();
|
||||
Account saved = _store.FindByName("Work")!;
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultTokenCaption_CountsOtherAccounts()
|
||||
{
|
||||
_store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null);
|
||||
_store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null);
|
||||
|
||||
Assert.Contains("2 other accounts", NewWizardWithDefault("ghp_default").DefaultTokenCaption);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void SeededWizard_PreselectsTheDefault_OnlyWhenTheSeedTokenIsTheDefaultToken()
|
||||
{
|
||||
var onDefault = new AccountWizardSeed("ghp_default", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8);
|
||||
var other = new AccountWizardSeed("ghp_other", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8);
|
||||
|
||||
Assert.True(new AccountWizardViewModel(_store, _orgs, onDefault, defaultToken: () => "ghp_default").UseDefaultToken);
|
||||
Assert.False(new AccountWizardViewModel(_store, _orgs, other, defaultToken: () => "ghp_default").UseDefaultToken);
|
||||
Assert.False(new AccountWizardViewModel(_store, _orgs, onDefault).UseDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void SeededWizard_NullSeed_Throws()
|
||||
{
|
||||
|
||||
@@ -388,6 +388,67 @@ public sealed class AccountsStoreConsistencyTests : IDisposable
|
||||
/// Delete can be made to throw (from a given call number on) without changing
|
||||
/// state — the contract a real secret store keeps on failure.
|
||||
/// </summary>
|
||||
// ---------------------------------------------------------------- save: switching to the default token (#102)
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchToDefaultToken_MetadataWriteFails_RestoresTheOwnToken()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, OldToken);
|
||||
BlockMetadataWrites();
|
||||
_vault.ResetCounters();
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { TokenSource = TokenSource.Default }, null));
|
||||
|
||||
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
|
||||
Assert.Equal(1, _vault.DeleteCalls);
|
||||
Assert.Equal(1, _vault.StoreCalls);
|
||||
Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken);
|
||||
Assert.False(Assert.Single(OnDisk()).UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchToDefaultToken_MetadataWriteFails_WithNoOwnToken_JustPropagates()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, OldToken);
|
||||
_vault.Delete(account.Id); // nothing of its own to put back
|
||||
BlockMetadataWrites();
|
||||
_vault.ResetCounters();
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { TokenSource = TokenSource.Default }, null));
|
||||
|
||||
Assert.Equal(0, _vault.DeleteCalls);
|
||||
Assert.Equal(0, _vault.StoreCalls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchToDefaultToken_MetadataWriteFails_AndRestoreFails_ReportsBoth()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, OldToken);
|
||||
BlockMetadataWrites();
|
||||
_vault.StoreFailure = new InvalidOperationException("vault offline");
|
||||
_vault.ResetCounters();
|
||||
|
||||
AccountConsistencyException ex = Assert.Throws<AccountConsistencyException>(
|
||||
() => store.Save(account with { TokenSource = TokenSource.Default }, null));
|
||||
|
||||
Assert.Contains("vault offline", ex.Message);
|
||||
Assert.Contains("lost its own token", ex.Message);
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.Contains(_log.Errors, e => e.Message.Contains("could not be restored", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
private sealed class FaultableVault : ITokenVault
|
||||
{
|
||||
private readonly InMemoryVault _inner = new();
|
||||
|
||||
@@ -42,6 +42,111 @@ public sealed class AccountsStoreTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
[Fact]
|
||||
public void Save_DefaultTokenAccount_WritesNoOwnEntry_AndRoundTripsTheSource()
|
||||
{
|
||||
Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
|
||||
|
||||
NewStore().Save(account, token: null);
|
||||
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Account reloaded = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(TokenSource.Default, reloaded.TokenSource);
|
||||
Assert.True(reloaded.UsesDefaultToken);
|
||||
Assert.Contains("\"TokenSource\": \"Default\"", File.ReadAllText(Path.Combine(_root, "accounts.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_OwnTokenAccount_OmitsTheSourceFromTheFile_SoOldFilesStayIdentical()
|
||||
{
|
||||
NewStore().Save(MakeAccount("Work"), "ghp_own");
|
||||
|
||||
string json = File.ReadAllText(Path.Combine(_root, "accounts.json"));
|
||||
Assert.DoesNotContain("TokenSource", json);
|
||||
Assert.Equal(TokenSource.Own, Assert.Single(NewStore().GetAll()).TokenSource);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Load_FileWithoutTheSourceField_ReadsAsOwn()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var id = Guid.NewGuid();
|
||||
File.WriteAllText(
|
||||
Path.Combine(_root, "accounts.json"),
|
||||
$$"""[{"Id":"{{id}}","Name":"Old","Organization":"acme","TargetRoot":"C:\\repos"}]""");
|
||||
|
||||
Account loaded = Assert.Single(NewStore().GetAll());
|
||||
|
||||
Assert.Equal(TokenSource.Own, loaded.TokenSource);
|
||||
Assert.False(loaded.UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_DefaultTokenAccount_WithAToken_IsRejected()
|
||||
{
|
||||
Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, "ghp_stray"));
|
||||
|
||||
Assert.Contains("default token", ex.Message);
|
||||
Assert.Empty(NewStore().GetAll());
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchingToTheDefaultToken_RemovesTheOwnEntry()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, "ghp_own");
|
||||
Assert.Equal("ghp_own", _vault.TryRetrieve(account.Id));
|
||||
|
||||
store.Save(account with { TokenSource = TokenSource.Default }, token: null);
|
||||
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.True(Assert.Single(store.GetAll()).UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchingBackToAnOwnToken_StoresIt()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work") with { TokenSource = TokenSource.Default };
|
||||
store.Save(account, token: null);
|
||||
|
||||
store.Save(account with { TokenSource = TokenSource.Own }, "ghp_mine");
|
||||
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(account.Id));
|
||||
Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void CountUsingDefaultToken_CountsOnlyDefaultTokenAccounts()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Assert.Equal(0, store.CountUsingDefaultToken());
|
||||
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());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- persistence
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -132,6 +132,69 @@ public sealed class SyncAllCoordinatorTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
/// <summary>A default-token account workspace; the default token is seeded only when asked.</summary>
|
||||
private WorkspaceViewModel CreateDefaultTokenWorkspace(string name, bool withDefaultToken)
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Organization = "org-" + name,
|
||||
TargetRoot = Path.Combine(_root, name),
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
if(withDefaultToken)
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "tok-default");
|
||||
}
|
||||
var workspace = new WorkspaceViewModel(
|
||||
_lister,
|
||||
new FakeGitClient(),
|
||||
_orgs,
|
||||
handler => new SyncProgress(handler),
|
||||
TimeSpan.FromMilliseconds(1),
|
||||
new NullActivityLog(),
|
||||
account,
|
||||
_vault);
|
||||
_workspaces.Add(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DefaultTokenAccount_WithoutADefaultToken_IsSkipped_NotRun()
|
||||
{
|
||||
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: false);
|
||||
Assert.True(shared.UsesDefaultToken);
|
||||
Assert.Equal("", shared.Token);
|
||||
var states = new List<SyncAllAccountState>();
|
||||
_coordinator.AccountStateChanged = (_, state) => states.Add(state);
|
||||
|
||||
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.Ran);
|
||||
Assert.Equal(1, result.Skipped);
|
||||
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Skipped], states);
|
||||
Assert.Empty(_lister.Organizations); // never asked GitHub: skipped before loading
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DefaultTokenAccount_WithADefaultToken_Runs()
|
||||
{
|
||||
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: true);
|
||||
Assert.Equal("tok-default", shared.Token);
|
||||
|
||||
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Ran);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ordering
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1793,6 +1793,55 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AccountConstruction_DefaultTokenAccount_UsesTheDefaultToken()
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Shared",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos\shared",
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
var vault = new InMemoryVault();
|
||||
vault.Store(AppSettings.DefaultTokenVaultId, "default-token-1234567890");
|
||||
string? lookedUpToken = null;
|
||||
_orgs.Handler = (token, _) =>
|
||||
{
|
||||
lookedUpToken = token;
|
||||
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
|
||||
};
|
||||
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault);
|
||||
|
||||
Assert.Equal("default-token-1234567890", vm.Token);
|
||||
Assert.True(vm.UsesDefaultToken);
|
||||
await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire");
|
||||
Assert.Equal("default-token-1234567890", lookedUpToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountConstruction_DefaultTokenAccount_WithoutADefaultToken_StartsEmpty()
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Shared",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos\shared",
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: new InMemoryVault());
|
||||
|
||||
Assert.Equal("", vm.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken()
|
||||
{
|
||||
|
||||
@@ -3,20 +3,37 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A saved connection profile: which GitHub organization to sync, where its clones
|
||||
/// live on disk, and how the sync runs. Metadata only — the access token is held
|
||||
/// separately in an <see cref="ITokenVault"/> keyed by <see cref="Id"/> and is never
|
||||
/// serialized alongside the account.
|
||||
/// separately in an <see cref="ITokenVault"/> (keyed by <see cref="Id"/>, or the
|
||||
/// default token when <see cref="TokenSource"/> says so) and is never serialized
|
||||
/// alongside the account.
|
||||
/// </summary>
|
||||
public sealed record Account
|
||||
{
|
||||
/// <summary>Stable identity: keys the vault entry and survives renames.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the token comes from: this account's own vault entry (the default, and
|
||||
/// omitted from accounts.json so files written before this field exist stay
|
||||
/// byte-identical) or the default token from Settings, resolved at use time by
|
||||
/// <see cref="AccountTokenResolver"/>.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<TokenSource>))]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public TokenSource TokenSource { get; init; } = TokenSource.Own;
|
||||
|
||||
/// <summary>True when the account resolves to the default token from Settings.</summary>
|
||||
[JsonIgnore]
|
||||
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
|
||||
|
||||
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The one place that turns an <see cref="Account"/> into the token to use for it:
|
||||
/// its own vault entry for <see cref="TokenSource.Own"/>, the default token from
|
||||
/// Settings for <see cref="TokenSource.Default"/>. The desktop workspace, Sync All,
|
||||
/// the account wizard and the CLI all resolve through here, so no consumer reads
|
||||
/// the vault for an account directly (docs/plans/default-token-ux.md).
|
||||
/// </summary>
|
||||
public sealed class AccountTokenResolver
|
||||
{
|
||||
private readonly ITokenVault _vault;
|
||||
|
||||
|
||||
|
||||
/// <summary>Resolves against <paramref name="vault"/>.</summary>
|
||||
public AccountTokenResolver(ITokenVault vault)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
_vault = vault;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The token for <paramref name="account"/>, or null when its source holds
|
||||
/// nothing (no default token saved, or the account's own entry is gone).
|
||||
/// </summary>
|
||||
public string? TryResolve(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
string? token = account.TokenSource == TokenSource.Default
|
||||
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
|
||||
: _vault.TryRetrieve(account.Id);
|
||||
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>
|
||||
/// Why <see cref="TryResolve"/> returned null for <paramref name="account"/>, worded
|
||||
/// for the user and naming the source, so every "no token" message says what to fix.
|
||||
/// Never contains a token.
|
||||
/// </summary>
|
||||
public static string MissingDescription(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TokenSource == TokenSource.Default
|
||||
? "uses the default token, but no default token is saved (desktop app: Settings)"
|
||||
: $"has no token in Windows Credential Manager (entry 'gclo:account:{account.Id:N}')";
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// </summary>
|
||||
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).
|
||||
/// </summary>
|
||||
private readonly Func<string?>? _defaultToken;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A wizard for a new account seeded from <paramref name="defaults"/>, or — when
|
||||
/// <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).
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public AccountWizardViewModel(
|
||||
AccountsStore store,
|
||||
@@ -50,8 +59,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
AppSettings defaults,
|
||||
Account? existing = null,
|
||||
Func<string?>? storedToken = null,
|
||||
IActivityLog? log = null)
|
||||
: this(store, orgLister, existing, storedToken, log)
|
||||
IActivityLog? log = null,
|
||||
Func<string?>? defaultToken = null)
|
||||
: this(store, orgLister, existing, storedToken, defaultToken, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(defaults);
|
||||
|
||||
@@ -85,13 +95,19 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// always written to the vault on save.
|
||||
/// </summary>
|
||||
public AccountWizardViewModel(
|
||||
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null)
|
||||
: this(store, orgLister, existing: null, storedToken: null, log)
|
||||
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null,
|
||||
Func<string?>? defaultToken = null)
|
||||
: this(store, orgLister, existing: null, storedToken: null, defaultToken, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seed);
|
||||
Name = "";
|
||||
Description = "";
|
||||
Token = seed.Token;
|
||||
// A Quick Sync connection that runs on the default token becomes a
|
||||
// default-token account; any other token is the account's own. Compared in
|
||||
// memory only; neither value is logged.
|
||||
UseDefaultToken = HasDefaultToken
|
||||
&& string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal);
|
||||
Organization = seed.Organization;
|
||||
TargetRoot = seed.TargetRoot;
|
||||
CreateOrgSubfolder = seed.CreateOrgSubfolder;
|
||||
@@ -101,7 +117,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
|
||||
|
||||
private AccountWizardViewModel(
|
||||
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken, IActivityLog? log)
|
||||
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken,
|
||||
Func<string?>? defaultToken, IActivityLog? log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(orgLister);
|
||||
@@ -110,6 +127,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
_log = log ?? new NullActivityLog();
|
||||
_existing = existing;
|
||||
_storedToken = existing is null ? null : storedToken;
|
||||
_defaultToken = defaultToken;
|
||||
// Decided once: the choice is offered only when a default token exists at
|
||||
// open time, and the value itself is fetched again when a step needs it.
|
||||
HasDefaultToken = !string.IsNullOrWhiteSpace(defaultToken?.Invoke());
|
||||
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
|
||||
|
||||
Step = 1;
|
||||
NameError = "";
|
||||
@@ -167,6 +189,59 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
public partial bool IsValidatingToken { get; set; }
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public bool HasDefaultToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Step 2's choice: true means the account resolves to the default token from
|
||||
/// Settings (<see cref="TokenSource.Default"/>) and the token box is disabled;
|
||||
/// false means a token of its own. Preselected for a new account when a default
|
||||
/// token exists, and from the account's current source for an edit.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsOwnTokenSelected))]
|
||||
[NotifyPropertyChangedFor(nameof(TokenChoiceIndex))]
|
||||
[NotifyPropertyChangedFor(nameof(DefaultTokenCaption))]
|
||||
public partial bool UseDefaultToken { get; set; }
|
||||
|
||||
/// <summary>The token box is enabled only for a token of the account's own.</summary>
|
||||
public bool IsOwnTokenSelected => !UseDefaultToken;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
|
||||
/// 0 = the default token, 1 = a token of this account's own.
|
||||
/// </summary>
|
||||
public int TokenChoiceIndex
|
||||
{
|
||||
get => UseDefaultToken ? 0 : 1;
|
||||
set => UseDefaultToken = value == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What choosing the default token means for this account, with how many other
|
||||
/// accounts share it; shown under the radio button.
|
||||
/// </summary>
|
||||
public string DefaultTokenCaption
|
||||
{
|
||||
get
|
||||
{
|
||||
int others = _store.CountUsingDefaultToken() - (_existing?.UsesDefaultToken == true ? 1 : 0);
|
||||
string shared = others switch
|
||||
{
|
||||
0 => "Used by Quick Sync.",
|
||||
1 => "Used by Quick Sync and 1 other account.",
|
||||
_ => $"Used by Quick Sync and {others} other accounts.",
|
||||
};
|
||||
string switching = IsEditing && _existing?.UsesDefaultToken == false && UseDefaultToken
|
||||
? " This account's own token will be removed from Windows Credential Manager on save."
|
||||
: "";
|
||||
return shared + " Replacing it in Settings updates this account too." + switching;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Step 1's validation message; empty when the name is acceptable.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string NameError { get; set; }
|
||||
@@ -254,16 +329,34 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
// token and returns the organizations the dropdown offers otherwise.
|
||||
// An edit with the box left empty validates the STORED token, fetched
|
||||
// here and used for this one call only.
|
||||
string candidate = Token.Trim();
|
||||
if(candidate.Length == 0 && IsEditing)
|
||||
string candidate;
|
||||
if(UseDefaultToken)
|
||||
{
|
||||
candidate = _storedToken?.Invoke()?.Trim() ?? "";
|
||||
// The default token is fetched for this one call only; the box
|
||||
// stays untouched (and disabled) so it is never shown or retained.
|
||||
candidate = _defaultToken?.Invoke()?.Trim() ?? "";
|
||||
if(candidate.Length == 0)
|
||||
{
|
||||
TokenError = "This account has no stored token. Enter one to continue.";
|
||||
TokenError = "The default token is no longer saved. Save one in Settings, "
|
||||
+ "or use a different token for this account.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate = Token.Trim();
|
||||
if(candidate.Length == 0 && IsEditing)
|
||||
{
|
||||
candidate = _storedToken?.Invoke()?.Trim() ?? "";
|
||||
if(candidate.Length == 0)
|
||||
{
|
||||
TokenError = _existing?.UsesDefaultToken == true
|
||||
? "Enter the token this account should use instead of the default one."
|
||||
: "This account has no stored token. Enter one to continue.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IsValidatingToken = true;
|
||||
_log.Info("Account wizard: validating the token.");
|
||||
@@ -333,7 +426,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
/// </summary>
|
||||
public Task SaveAsync()
|
||||
{
|
||||
_store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null);
|
||||
// A default-token account stores no token of its own (the store removes any
|
||||
// leftover entry when an account switches); otherwise new accounts always
|
||||
// persist their token and edits only when one was typed.
|
||||
_store.Save(BuildAccount(), !UseDefaultToken && TokenChanged ? Token.Trim() : null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -353,6 +449,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
|
||||
TargetRoot = TargetRoot.Trim(),
|
||||
CreateOrgSubfolder = CreateOrgSubfolder,
|
||||
MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency),
|
||||
TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own,
|
||||
LastSyncUtc = _existing?.LastSyncUtc,
|
||||
LastSyncSummary = _existing?.LastSyncSummary,
|
||||
};
|
||||
|
||||
@@ -136,6 +136,17 @@ public sealed class AccountsStore
|
||||
}
|
||||
|
||||
string verb = index >= 0 ? "updated" : "added";
|
||||
if(account.UsesDefaultToken)
|
||||
{
|
||||
if(token is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"An account that uses the default token has no token of its own to store.", nameof(token));
|
||||
}
|
||||
SaveUsingDefaultToken(updated, account, verb);
|
||||
return;
|
||||
}
|
||||
|
||||
if(token is null)
|
||||
{
|
||||
Persist(updated); // IO failures propagate; _accounts stays unchanged then.
|
||||
@@ -191,6 +202,62 @@ public sealed class AccountsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Saves an account that resolves to the default token: its metadata is written
|
||||
/// and any token of its own is removed from the vault, vault first, with the same
|
||||
/// put-it-back compensation as a delete. Called under the gate.
|
||||
/// </summary>
|
||||
private void SaveUsingDefaultToken(List<Account> updated, Account account, string verb)
|
||||
{
|
||||
string? ownToken = _vault.TryRetrieve(account.Id);
|
||||
if(ownToken is not null)
|
||||
{
|
||||
_vault.Delete(account.Id);
|
||||
}
|
||||
try
|
||||
{
|
||||
Persist(updated);
|
||||
}
|
||||
catch(Exception persistFailure)
|
||||
{
|
||||
if(ownToken is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
try
|
||||
{
|
||||
_vault.Store(account.Id, ownToken);
|
||||
}
|
||||
catch(Exception compensationFailure)
|
||||
{
|
||||
string leftover = $"account {account.Id:N} ('{account.Name}') lost its own token from the vault while accounts.json "
|
||||
+ "still says it has one; edit it and enter the token again, or switch it to the default token";
|
||||
_log.Error($"Account save failed and the vault could not be restored: {leftover}.", compensationFailure);
|
||||
throw new AccountConsistencyException(
|
||||
$"Saving account '{account.Name}' failed ({persistFailure.Message}), and restoring its token "
|
||||
+ $"failed too ({compensationFailure.Message}). Current state: {leftover}.",
|
||||
persistFailure, compensationFailure);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
_accounts = updated;
|
||||
_log.Info($"Account '{account.Name}' {verb}; it uses the default token"
|
||||
+ (ownToken is null ? "." : " (its own token was removed from the vault)."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>How many accounts resolve to the default token (Settings shows what a removal breaks).</summary>
|
||||
public int CountUsingDefaultToken()
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
return _accounts.Count(a => a.UsesDefaultToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the account's vault token and then its metadata; an unknown id is a
|
||||
/// no-op. A vault failure changes nothing. If the metadata write fails after the
|
||||
|
||||
@@ -78,6 +78,16 @@ public sealed class SyncAllCoordinator(IActivityLog log)
|
||||
continue;
|
||||
}
|
||||
|
||||
if(workspace.UsesDefaultToken && string.IsNullOrEmpty(workspace.Token))
|
||||
{
|
||||
// The account resolves to the default token and none is saved: a
|
||||
// generic auth failure would send the user to the wrong place.
|
||||
skipped++;
|
||||
Announce(workspace, SyncAllAccountState.Skipped);
|
||||
_log.Info($"Sync all: skipped '{workspace.DisplayName}' — no default token is saved (Settings).");
|
||||
continue;
|
||||
}
|
||||
|
||||
Announce(workspace, SyncAllAccountState.Running);
|
||||
|
||||
if(!workspace.HasLoadedRepos)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
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).
|
||||
/// </summary>
|
||||
public enum TokenSource
|
||||
{
|
||||
/// <summary>The account's own token, stored under its id. The on-disk default.</summary>
|
||||
Own = 0,
|
||||
|
||||
/// <summary>The default token from Settings, resolved whenever the account is used.</summary>
|
||||
Default = 1,
|
||||
}
|
||||
@@ -140,8 +140,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
TargetFolder = account.TargetRoot;
|
||||
CreateOrgSubfolder = account.CreateOrgSubfolder;
|
||||
MaxConcurrency = account.MaxConcurrency;
|
||||
// The Token setter's existing org lookup fires naturally with the vault token.
|
||||
Token = tokenVault?.TryRetrieve(account.Id) ?? "";
|
||||
// The Token setter's existing org lookup fires naturally with the resolved
|
||||
// token: the account's own entry, or the default token from Settings.
|
||||
Token = tokenVault is null ? "" : new AccountTokenResolver(tokenVault).TryResolve(account) ?? "";
|
||||
}
|
||||
_constructed = true;
|
||||
}
|
||||
@@ -156,6 +157,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Name shown for this workspace in navigation: the account's name, or "Quick Sync".</summary>
|
||||
public string DisplayName => _account?.Name ?? "Quick Sync";
|
||||
|
||||
/// <summary>
|
||||
/// True for an account workspace whose token is the default token from Settings
|
||||
/// (<see cref="TokenSource.Default"/>): the pane tags it, and Sync All names
|
||||
/// Settings when that token is missing.
|
||||
/// </summary>
|
||||
public bool UsesDefaultToken => _account?.UsesDefaultToken == true;
|
||||
|
||||
|
||||
|
||||
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
|
||||
|
||||
@@ -57,12 +57,31 @@
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Step 2 of 4: Access token" />
|
||||
<!-- Offered only when a default token is saved in Settings (#102). The
|
||||
account then references that token instead of holding a copy, so
|
||||
replacing it in Settings updates every such account. -->
|
||||
<RadioButtons
|
||||
x:Name="TokenChoice"
|
||||
AutomationProperties.AutomationId="WizardTokenChoice"
|
||||
Header="Which token should this account use?"
|
||||
SelectedIndex="{x:Bind ViewModel.TokenChoiceIndex, Mode=TwoWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasDefaultToken), Mode=OneWay}">
|
||||
<RadioButton AutomationProperties.AutomationId="WizardUseDefaultToken" Content="Use the default token from Settings" />
|
||||
<RadioButton AutomationProperties.AutomationId="WizardUseOwnToken" Content="Use a different token for this account" />
|
||||
</RadioButtons>
|
||||
<TextBlock
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="{x:Bind ViewModel.DefaultTokenCaption, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.UseDefaultToken), Mode=OneWay}" />
|
||||
<PasswordBox
|
||||
x:Name="TokenBox"
|
||||
AutomationProperties.AutomationId="WizardTokenBox"
|
||||
Header="Personal access token"
|
||||
ToolTipService.ToolTip="Stored in Windows Credential Manager, never in a file; needs repository read access"
|
||||
PlaceholderText="ghp_…"
|
||||
IsEnabled="{x:Bind ViewModel.IsOwnTokenSelected, Mode=OneWay}"
|
||||
PasswordChanged="TokenBox_PasswordChanged" />
|
||||
<HyperlinkButton
|
||||
Padding="0"
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed partial class AccountWizardDialog : ContentDialog
|
||||
// by hand. The stored token of an existing account is deliberately NOT
|
||||
// loaded into the box: an empty box means "keep it", and the secret never
|
||||
// sits in an editable control with a reveal button (#32).
|
||||
TokenBox.PlaceholderText = ViewModel.IsEditing
|
||||
TokenBox.PlaceholderText = ViewModel.IsEditing && !ViewModel.UseDefaultToken
|
||||
? "Leave empty to keep the stored token"
|
||||
: "ghp_…";
|
||||
|
||||
|
||||
+34
-4
@@ -615,11 +615,12 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
var item = new NavigationViewItem
|
||||
{
|
||||
Content = account.Name,
|
||||
Content = AccountItemContent(account),
|
||||
Tag = account.Id,
|
||||
Icon = new FontIcon { Glyph = "" },
|
||||
ContextFlyout = CreateAccountContextFlyout(account.Id),
|
||||
};
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name);
|
||||
ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
|
||||
_navItems[account.Id] = item;
|
||||
return item;
|
||||
@@ -627,6 +628,31 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The pane item's content: the name, plus a "Default token" tag for an account
|
||||
/// that resolves to the default token from Settings, so the dependency on
|
||||
/// Settings is visible where the account is used (#102).
|
||||
/// </summary>
|
||||
private static object AccountItemContent(Account account)
|
||||
{
|
||||
if(!account.UsesDefaultToken)
|
||||
{
|
||||
return account.Name;
|
||||
}
|
||||
var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||||
panel.Children.Add(new TextBlock { Text = account.Name, VerticalAlignment = VerticalAlignment.Center });
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Default token",
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
|
||||
Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The Edit/Delete context menu for an account's pane item.</summary>
|
||||
private MenuFlyout CreateAccountContextFlyout(Guid id)
|
||||
{
|
||||
@@ -688,7 +714,8 @@ public sealed partial class MainWindow : Window
|
||||
// validation call; the wizard never displays or retains it.
|
||||
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id);
|
||||
var viewModel = new AccountWizardViewModel(
|
||||
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log);
|
||||
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log,
|
||||
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
@@ -737,7 +764,9 @@ public sealed partial class MainWindow : Window
|
||||
/// </summary>
|
||||
private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed)
|
||||
{
|
||||
var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log);
|
||||
var viewModel = new AccountWizardViewModel(
|
||||
_accountsStore, new GitHubOrganizationLister(), seed, _log,
|
||||
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
var dialog = new AccountWizardDialog(
|
||||
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
|
||||
{
|
||||
@@ -798,7 +827,8 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
if(_navItems.TryGetValue(id, out NavigationViewItem? item))
|
||||
{
|
||||
item.Content = account.Name;
|
||||
item.Content = AccountItemContent(account);
|
||||
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name);
|
||||
ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user