From 82e70744c2869f4af78403706b73da194d08be7e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 15:14:25 -0700 Subject: [PATCH] Accounts: GitHub link and the "Reconnect for GitHub Issues" scope handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GitHub panel to Connected Accounts as a self-contained `GitHubConnectionPanel` — one line of hosting, and `ConnectedAccountsViewModel` is not touched at all (PR #154 is editing its `LoadAsync` and appending a LinkedIn panel to the end of the same view; this lands in a different region and shares no members). - "Reconnect for GitHub Issues" opens `api/auth/github/authorize?link=true` in the OS default browser. `?link=true` is the whole mechanism: re-verified live by reading the 307 `Location` both ways, it upgrades the requested scope from `user:email read:user` to `user:email read:user repo read:org`. - "Manage organization access" is offered as a **separate** action from `manageOrgAccessUrl`, because re-running OAuth with unchanged scopes returns silently — a reconnect genuinely cannot grant an organization's approval. - Link state comes from `GET /api/user/identities` via the shared `GitHubLinkViewModel`, so this panel and the GitHub-backed list flow can never disagree about whether GitHub is connected. - The Issues scope **cannot** be detected, and the panel says so plainly rather than drawing a confident tick: `/api/user/identities` has no scope field, and `/api/auth/github/status` reports server configuration, not user grants. A "Check GitHub access" action reports exactly what came back and names the ambiguity — an empty repository list means "owns none" just as readily as "sign-in only". - Returning from the browser and refreshing re-reads the link: the panel's own "Check again", the host's existing Refresh (via `ReloadSignal`), and re-entering the view all reload it. Closes #76 Co-Authored-By: Claude Opus 5 --- .../ViewModels/GitHubConnectionViewModel.cs | 130 +++++++++++ .../Views/ConnectedAccountsView.xaml | 2 + .../Views/GitHubConnectionPanel.xaml | 205 ++++++++++++++++++ .../Views/GitHubConnectionPanel.xaml.cs | 74 +++++++ 4 files changed, 411 insertions(+) create mode 100644 InterlinedList/ViewModels/GitHubConnectionViewModel.cs create mode 100644 InterlinedList/Views/GitHubConnectionPanel.xaml create mode 100644 InterlinedList/Views/GitHubConnectionPanel.xaml.cs diff --git a/InterlinedList/ViewModels/GitHubConnectionViewModel.cs b/InterlinedList/ViewModels/GitHubConnectionViewModel.cs new file mode 100644 index 0000000..9db6962 --- /dev/null +++ b/InterlinedList/ViewModels/GitHubConnectionViewModel.cs @@ -0,0 +1,130 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// The GitHub panel in Connected Accounts: what the link is, the Reconnect for +/// GitHub Issues handoff, the separate Manage organization access +/// handoff, and an access check that is honest about what it can and cannot +/// prove. +/// +/// +/// Composes rather than re-reading link state, +/// so this panel and the GitHub-backed-list flow can never disagree about whether +/// GitHub is connected. +/// +/// +/// +/// Why there is no scope indicator. #76 asks that link state come from +/// GET /api/user/identities and that a scope we cannot detect be said +/// plainly rather than guessed. Both matter here: +/// +/// +/// /api/user/identities is the only per-user signal and +/// carries no scope field — provider, username, profile/avatar URLs, +/// connectedAt, lastVerifiedAt, and that is all (verified live +/// 2026-09-16). +/// /api/auth/github/status is a red herring for +/// this purpose, exactly as CLAUDE.md warns: it answers +/// { configured: true, clientId: "Ov23li9eXYK1i6psJW6G", manageOrgAccessUrl: +/// "…/settings/connections/applications/Ov23li9eXYK1i6psJW6G" } — server +/// configuration, not user grants. Its one genuinely useful field is +/// manageOrgAccessUrl, which nothing else publishes. +/// +/// +/// So a sign-in-only link is indistinguishable from an Issues-capable one until a +/// call behaves differently, and this panel says so instead of drawing a +/// confident green tick. +/// +/// +public partial class GitHubConnectionViewModel : ObservableObject +{ + private readonly SessionService _session; + + public GitHubLinkViewModel Link { get; } + + [ObservableProperty] + private bool isChecking; + + [ObservableProperty] + private string? accessCheckResult; + + public GitHubConnectionViewModel(SessionService session) + { + _session = session; + Link = new GitHubLinkViewModel(session); + Link.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(GitHubLinkViewModel.LinkState) or nameof(GitHubLinkViewModel.IsLinked)) + OnPropertyChanged(nameof(DefaultRepoLine)); + }; + } + + /// + /// The user's githubDefaultRepo — the repository + /// GET /api/github/issues falls back to when called without one, and a + /// sensible pre-selection in any repo picker. Null on the test account, so the + /// "not set" branch is the observed one. + /// + public string DefaultRepoLine => Link.DefaultRepo is { Length: > 0 } repo + ? $"Default repository: {repo}" + : "No default repository set — pickers will ask every time."; + + [RelayCommand] + private async Task LoadAsync() => await Link.LoadAsync(); + + /// + /// Tries the one read that a sign-in-only link would most plausibly differ on, + /// and reports exactly what came back — including when the answer is + /// ambiguous. + /// + /// + /// The ambiguity is real and worth naming rather than papering over: bare + /// GET /api/github/repos returns 200 [] on the account this was + /// built against, which owns no repositories while being properly linked. An + /// empty array is therefore not evidence of a missing scope, and a non-empty + /// one is not proof of one either — GitHub lists public repositories without + /// the repo scope. What this check can do is turn a refusal + /// into the right remedy, and turn "nothing happened" into a sentence the user + /// can act on. + /// + /// + [RelayCommand] + private async Task CheckIssuesAccessAsync() + { + if (!Link.IsLinked) + { + AccessCheckResult = "GitHub isn't connected to this account yet, so there's nothing to check."; + return; + } + + IsChecking = true; + try + { + var repos = await _session.Api.GetGitHubReposAsync(null); + + AccessCheckResult = repos.Count > 0 + ? $"{repos.Count:N0} repositor{(repos.Count == 1 ? "y" : "ies")} readable through the link. " + + "That confirms the link works — it still can't prove the Issues scope on its own, since " + + "public repositories list without it. If creating a GitHub-backed list is refused, reconnect." + : "No repositories came back, and that's ambiguous: this GitHub account may simply own none " + + "(true of the account this app was built against), or the link may be sign-in-only. " + + "Reconnect for GitHub Issues to rule the second one out. For an organization's " + + "repositories, use the organization filter in the GitHub-backed list flow."; + } + catch (InterlinedApiException ex) + { + AccessCheckResult = Link.ExplainFailure(ex); + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException) + { + AccessCheckResult = "Couldn't reach InterlinedList to check GitHub access."; + } + finally + { + IsChecking = false; + } + } +} diff --git a/InterlinedList/Views/ConnectedAccountsView.xaml b/InterlinedList/Views/ConnectedAccountsView.xaml index 4877776..767f570 100644 --- a/InterlinedList/Views/ConnectedAccountsView.xaml +++ b/InterlinedList/Views/ConnectedAccountsView.xaml @@ -114,6 +114,8 @@ Margin="0,0,0,8" Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}"/> + + diff --git a/InterlinedList/Views/GitHubConnectionPanel.xaml b/InterlinedList/Views/GitHubConnectionPanel.xaml new file mode 100644 index 0000000..b539116 --- /dev/null +++ b/InterlinedList/Views/GitHubConnectionPanel.xaml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +