Skip to content

Accounts: GitHub link and the "Reconnect for GitHub Issues" scope handoff (#76) - #178

Merged
Adron merged 2 commits into
mainfrom
issue-76-github-reconnect
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-76-github-reconnect

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack

main → #142 (issue-72-github-service) → #175 (issue-73-github-backed-list) → #176 (issue-74-repo-link-refresh) → this PR. Base is issue-74-repo-link-refresh, not main.

What this adds

A GitHub panel in Connected Accounts — Views/GitHubConnectionPanel.xaml(.cs) + ViewModels/GitHubConnectionViewModel.cs:

  • Reconnect for GitHub Issues → the OS browser.
  • Manage organization access → a separate action, from manageOrgAccessUrl.
  • Check again and Check GitHub access.
  • Status, connectedAt / lastVerifiedAt, and the user's githubDefaultRepo.

Hosting cost in ConnectedAccountsView.xaml: 1 line, and zero view-model edits

<local:GitHubConnectionPanel ReloadSignal="{Binding Identities.Count}"/>

The issue allows editing ConnectedAccountsViewModel.cs, but nothing needed to change there, which is the better outcome given PR #154: that PR edits the view model's LoadAsync and appends a LinkedIn Pages panel to the end of the same view. This inserts before the linked-accounts list and shares no members, so the two should reconcile cleanly.

ReloadSignal is the one bit of host coupling and its value is never read — Identities.Count changes on both the Clear() and the re-fill inside the host's LoadAsync, so the existing Refresh button refreshes this panel too. The panel also reloads on its own Loaded (which fires again when the view is swapped back in — exactly when the user has returned from the browser) and has its own Check again, so the binding is optional.

?link=true is the whole mechanism — re-verified this session

Reading the 307 Location both ways, live:

GET /api/auth/github/authorize
  → 307 …github.com/login/oauth/authorize?…&scope=user%3Aemail+read%3Auser&state=…
GET /api/auth/github/authorize?link=true
  → 307 …github.com/login/oauth/authorize?…&scope=user%3Aemail+read%3Auser+repo+read%3Aorg&state=…

So sign-in-only is user:email read:user; ?link=true is what adds repo read:org — the Issues access a GitHub-backed list needs. A ?scope= of our own is ignored and an arbitrary redirect_uri is rejected, so the URL is handed over exactly as the server builds it. No WebView2: the browser opens, the user comes back and refreshes, as the cross-post providers already do.

"Manage organization access" is not a second Reconnect button

GET /api/auth/github/status → { configured: true, clientId: "Ov23li9eXYK1i6psJW6G", manageOrgAccessUrl: "https://github.com/settings/connections/applications/Ov23li9eXYK1i6psJW6G" } (live, 200 with the bearer token).

Missing organization repositories are an org-approval problem, and re-running OAuth with unchanged scopes returns silently — so a reconnect cannot fix it. That's why this is its own action with its own explanation, and it's the one thing /api/auth/github/status is genuinely good for.

Link state from /api/user/identities, and the scope said plainly

Per the issue and CLAUDE.md, /api/auth/{provider}/status is a red herring for per-user state. Live:

GET /api/user/identities → 200
  { provider: "github", providerUsername: "InterlinedListMessenger",
    profileUrl: …, avatarUrl: …, connectedAt: "2026-08-22T06:03:43.114Z",
    lastVerifiedAt: "2026-09-15T19:15:32.423Z" }

No scope field — provider, username, URLs, timestamps, and that's all. And /api/auth/github/status reports server configuration, not user grants. So:

If the scope cannot be detected from GET /api/user/identities, say so plainly rather than guessing.

It can't, and the panel says so in two places (GitHubLinkViewModel.ScopeNote and the panel's own copy) instead of drawing a confident green tick. Check GitHub access then reports exactly what a real read returned, including the ambiguity:

  • repositories came back → "that confirms the link works — it still can't prove the Issues scope on its own, since public repositories list without it";
  • nothing came back → "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";
  • refused → the specific remedy, reconnect vs org access.

That last point is the trap this avoids: GET /api/github/repos returns 200 [] on an account that is linked, so an empty collection must never be read as "not connected".

Verified live (test account, 2026-09-16)

  • Both authorize redirects, as quoted above.
  • GET /api/auth/github/status → the three fields quoted above.
  • GET /api/user/identities → four providers, GitHub among them, no scope field anywhere.
  • dotnet build -c Debug and -c Release — both green, 0 warnings.

Deliberately not called

  • GET /api/auth/github/callback and POST /api/auth/{provider}/link. Completing the OAuth round trip would re-grant scopes on the shared test account's real GitHub identity (InterlinedListMessenger) and rewrite its stored token. The handoff is built and the redirect it depends on is verified; the return leg needs a human in a browser.
  • The genuinely-unlinked response shape is still unobserved — the account is linked, so "not connected" copy is built from the documented contract rather than from an observed response.
  • No writes to GitHub repositories, and the account's own content was left alone.

Closes #76

🤖 Generated with Claude Code

…doff

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 <noreply@anthropic.com>
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Excellent stack. Two notes.

1. The logout observation isn't a contradiction of #148 — it's #148's own headline finding

You wrote that POST /api/auth/logout leaves the bearer sync-token valid, and that this "contradicts PR #148's premise ('guarantee the local token is destroyed')".

That's a misreading of "local". #148's premise is that the token is destroyed on disk (%LocalAppData%\InterlinedList\session.dat), and its headline conclusion is exactly what you observed: logout does not invalidate the sync-token server-side. From its own report: "Does POST /api/auth/logout invalidate a bearer sync-token? No. It is a cookie-session endpoint." It returns 200 even with no Authorization header at all (x-auth-type: "none", security: []).

So we agree, and no change is needed. I'm noting it because three separate agents have now probed logout and I'd rather not leave a phantom disagreement in the record. It's in CLAUDE.md on #129.

2. Your self-revoke finding is right, and it settles a disagreement

You found DELETE /api/user/sessions/{own-id} → 400 cannot_revoke_current_session. That matches #114 and my own test:

DELETE own session -> 400 {"error":"cannot_revoke_current_session"}
GET /api/user x3   -> 200, 200, 200   (nothing was revoked)
row still present  -> True

The #46 agent reported the opposite ("a sync-token can revoke itself"), which I'd already corrected on #167. Your independent confirmation closes it. It matters because #166's bulk-revoke deliberately skips the current session — that's load-bearing, not caution.

Your token 57a2d1d2 is revoked; the other two you'd already handled.


What I particularly value in this stack

You didn't fake an unmeetable criterion. #73 asked that a "missing Issues scope is detected up front", and you established there is no scope introspection anywhere — /api/user/identities carries provider/username/URLs/timestamps and no scope field, and /api/auth/github/status is server config. Saying so plainly and gating on real link state, while keeping reconnect always available and separating it from "grant org access", is the right answer. That was my criterion and it was not achievable as written.

The hosting footprint is remarkable — 8 lines across two contended files for three issues, and ConnectedAccountsViewModel.cs needed no change at all despite being permitted. Reusing the host's existing local-form as the Local tab rather than rebuilding it is exactly right.

The 2000 cap is now well-established: six orgs at exactly 2000 (microsoft, google, apache, Azure, mozilla, IBM) against natural counts from 2 to 1360 across ~13,000 further items. "First 2,000, not all of them" is honest.

Models/GitHubListBacking.cs projecting the three fields out of raw list JSON is a good call given #133 isn't in your base — it merges cleanly either way and keeps githubRepoPrivate as bool? with the three-state discipline intact.

One thing worth its own issue

You found POST /api/lists accepts eleven fields (source, githubRepo, githubSource, initialRows, …) while CreateListAsync sends two. That's a real gap beyond this stack's scope — initialRows in particular would let Powered Templates (#174) and Materialize (#136) create a populated list in one call instead of N+1 requests. I'll file it.

On Views/GitHubMark.xaml: carrying the Octicons mark-github path with MIT attribution in a comment is fine and correctly attributed. Flagging it for the author's call rather than changing it.

@Adron
Adron changed the base branch from issue-74-repo-link-refresh to main September 24, 2026 06:22
@Adron
Adron merged commit 2a7986b into main Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHub: link and "Reconnect for GitHub Issues" scope handoff

1 participant