Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions InterlinedList/ViewModels/GitHubConnectionViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using InterlinedList.Services;

namespace InterlinedList.ViewModels;

/// <summary>
/// The GitHub panel in Connected Accounts: what the link is, the <b>Reconnect for
/// GitHub Issues</b> handoff, the separate <b>Manage organization access</b>
/// handoff, and an access check that is honest about what it can and cannot
/// prove.
///
/// <para>
/// Composes <see cref="GitHubLinkViewModel"/> rather than re-reading link state,
/// so this panel and the GitHub-backed-list flow can never disagree about whether
/// GitHub is connected.
/// </para>
///
/// <para>
/// <b>Why there is no scope indicator.</b> #76 asks that link state come from
/// <c>GET /api/user/identities</c> and that a scope we cannot detect be said
/// plainly rather than guessed. Both matter here:
/// </para>
/// <list type="bullet">
/// <item><description><c>/api/user/identities</c> is the only per-user signal and
/// carries <b>no scope field</b> — provider, username, profile/avatar URLs,
/// <c>connectedAt</c>, <c>lastVerifiedAt</c>, and that is all (verified live
/// 2026-09-16).</description></item>
/// <item><description><c>/api/auth/github/status</c> is a <b>red herring</b> for
/// this purpose, exactly as CLAUDE.md warns: it answers
/// <c>{ configured: true, clientId: "Ov23li9eXYK1i6psJW6G", manageOrgAccessUrl:
/// "…/settings/connections/applications/Ov23li9eXYK1i6psJW6G" }</c> — server
/// configuration, not user grants. Its one genuinely useful field is
/// <c>manageOrgAccessUrl</c>, which nothing else publishes.</description></item>
/// </list>
/// <para>
/// 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.
/// </para>
/// </summary>
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));
};
}

/// <summary>
/// The user's <c>githubDefaultRepo</c> — the repository
/// <c>GET /api/github/issues</c> 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.
/// </summary>
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();

/// <summary>
/// Tries the one read that a sign-in-only link would most plausibly differ on,
/// and reports <b>exactly</b> what came back — including when the answer is
/// ambiguous.
///
/// <para>
/// The ambiguity is real and worth naming rather than papering over: bare
/// <c>GET /api/github/repos</c> returns <c>200 []</c> 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 <c>repo</c> scope. What this check <em>can</em> do is turn a refusal
/// into the right remedy, and turn "nothing happened" into a sentence the user
/// can act on.
/// </para>
/// </summary>
[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;
}
}
}
2 changes: 2 additions & 0 deletions InterlinedList/Views/ConnectedAccountsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
Margin="0,0,0,8"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}"/>

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

<!-- Linked accounts -->
<ItemsControl ItemsSource="{Binding Identities}" Margin="0,0,0,16">
<ItemsControl.ItemTemplate>
Expand Down
205 changes: 205 additions & 0 deletions InterlinedList/Views/GitHubConnectionPanel.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<UserControl x:Class="InterlinedList.Views.GitHubConnectionPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:InterlinedList.Views"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignWidth="660">

<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<local:NullOrEmptyToVisibilityConverter x:Key="NullOrEmptyToVisibility"/>

<Style x:Key="PanelSmallBtnStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,3"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

<Style x:Key="PanelPrimaryBtnStyle" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource PrimaryBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource PrimaryHoverBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>

<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="4"
Padding="16"
Margin="0,0,0,16">
<StackPanel>

<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
<local:GitHubMark Width="15" Height="15"
VerticalAlignment="Center"
Margin="0,0,7,0"
Foreground="{DynamicResource TextBrush}"/>
<TextBlock Text="GitHub"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
</StackPanel>

<TextBlock Text="{Binding Link.StatusLine}"
FontSize="12"
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"
Margin="0,0,0,2"/>

<TextBlock FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
Margin="0,0,0,2"
Visibility="{Binding Link.IsLinked, Converter={StaticResource BoolToVisibility}}">
<Run Text="Connected"/>
<Run Text="{Binding Link.LinkState.ConnectedAt, StringFormat={}{0:d}, Mode=OneWay}"/>
<Run Text="· last verified"/>
<Run Text="{Binding Link.LinkState.LastVerifiedAt, StringFormat={}{0:d}, Mode=OneWay}"/>
</TextBlock>

<TextBlock Text="{Binding DefaultRepoLine}"
FontSize="11"
FontFamily="JetBrains Mono, Consolas"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"
Margin="0,0,0,10"
Visibility="{Binding Link.IsLinked, Converter={StaticResource BoolToVisibility}}"/>

<!-- Sign-in-only vs Issues-capable: the app cannot tell, and says so
rather than drawing a confident tick. /api/user/identities has no
scope field, and /api/auth/github/status reports server config, not
user grants. -->
<Border Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="3"
Padding="10,8"
Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="GitHub-backed lists need the Issues scope"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
Margin="0,0,0,4"/>
<TextBlock Text="{Binding Link.ReconnectExplanation}"
FontSize="11"
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"
Margin="0,0,0,6"/>
<TextBlock Text="{Binding Link.ScopeNote}"
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"/>
</StackPanel>
</Border>

<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button Content="{Binding Link.ReconnectLabel}"
Command="{Binding Link.ReconnectCommand}"
Style="{StaticResource PanelPrimaryBtnStyle}"
Margin="0,0,8,0"/>
<Button Content="Check again"
Command="{Binding LoadCommand}"
Style="{StaticResource PanelSmallBtnStyle}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<Button Content="Check GitHub access"
Command="{Binding CheckIssuesAccessCommand}"
Style="{StaticResource PanelSmallBtnStyle}"
VerticalAlignment="Center"
Margin="0,0,6,0"
Visibility="{Binding Link.IsLinked, Converter={StaticResource BoolToVisibility}}"/>
<TextBlock Text="Checking…"
FontSize="11"
VerticalAlignment="Center"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding IsChecking, Converter={StaticResource BoolToVisibility}}"/>
</StackPanel>

<!-- A DIFFERENT remedy, deliberately not a second Reconnect button:
re-running OAuth with unchanged scopes returns silently, so only
GitHub's authorized-apps page can approve an organization. The URL
comes from manageOrgAccessUrl, which is the one thing
/api/auth/github/status is actually good for. -->
<StackPanel Visibility="{Binding Link.CanManageOrgAccess, Converter={StaticResource BoolToVisibility}}"
Margin="0,0,0,8">
<Button Content="Manage organization access on GitHub"
Command="{Binding Link.ManageOrgAccessCommand}"
Style="{StaticResource PanelSmallBtnStyle}"
HorizontalAlignment="Left"
Margin="0,0,0,4"/>
<TextBlock Text="If an organization's repositories are missing, that's org approval — not a missing scope. Reconnecting can't fix it; granting InterlinedList access to the organization on GitHub can."
FontSize="11"
Foreground="{DynamicResource TextMutedBrush}"
TextWrapping="Wrap"/>
</StackPanel>

<TextBlock Text="{Binding Link.HandoffNotice}"
FontSize="11"
Foreground="{DynamicResource PrimaryBrush}"
TextWrapping="Wrap"
Margin="0,0,0,4"
Visibility="{Binding Link.HandoffNotice, Converter={StaticResource NullOrEmptyToVisibility}}"/>

<TextBlock Text="{Binding AccessCheckResult}"
FontSize="11"
Foreground="{DynamicResource TextBodyBrush}"
TextWrapping="Wrap"
Margin="0,0,0,4"
Visibility="{Binding AccessCheckResult, Converter={StaticResource NullOrEmptyToVisibility}}"/>

<TextBlock Text="{Binding Link.ErrorMessage}"
Foreground="#FFE81123"
FontSize="11"
TextWrapping="Wrap"
Visibility="{Binding Link.ErrorMessage, Converter={StaticResource NullOrEmptyToVisibility}}"/>
</StackPanel>
</Border>
</UserControl>
Loading
Loading