feat(ui): Online Inbox settings tab + auth-code/PKCE login

New Settings tab: enable toggle, config fields, sign-in/out + status.
OnlineLoginService runs the PKCE loopback flow (Duende.IdentityModel.OidcClient
7.1.0), opens the system browser, captures the callback, hands the refresh
token to the Worker. en/de localized. Fixes: loopback callback URL built from
host:port base (avoids doubled redirect path); PollIntervalSeconds threaded
through the state DTO so it loads instead of resetting to 60.

Visual layout + the live sign-in round-trip need manual verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-10 11:02:14 +02:00
parent 17c7ff517a
commit 80a2de6c74
11 changed files with 415 additions and 3 deletions

View File

@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,10 @@
namespace ClaudeDo.Ui.Services;
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error);
public interface IOnlineLoginService
{
Task<OnlineLoginResult> LoginAsync(
string authority, string clientId, string scope, string redirectUri,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,137 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
namespace ClaudeDo.Ui.Services;
public sealed class OnlineLoginService : IOnlineLoginService
{
public async Task<OnlineLoginResult> LoginAsync(
string authority, string clientId, string scope, string redirectUri,
CancellationToken ct = default)
{
try
{
var browser = new LoopbackBrowser(redirectUri);
var options = new OidcClientOptions
{
Authority = authority,
ClientId = clientId,
Scope = scope,
RedirectUri = redirectUri,
Browser = browser,
};
var client = new OidcClient(options);
var result = await client.LoginAsync(new LoginRequest(), ct);
if (result.IsError)
return new OnlineLoginResult(false, null, result.Error);
if (string.IsNullOrEmpty(result.RefreshToken))
return new OnlineLoginResult(false, null,
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
return new OnlineLoginResult(true, result.RefreshToken, null);
}
catch (OperationCanceledException)
{
return new OnlineLoginResult(false, null, "Login cancelled.");
}
catch (Exception ex)
{
return new OnlineLoginResult(false, null, ex.Message);
}
}
}
/// <summary>
/// IBrowser implementation: opens the system browser and captures the authorization
/// response via a loopback HttpListener on the redirect URI's host/port.
/// </summary>
sealed class LoopbackBrowser : IBrowser
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3);
private readonly string _redirectUri;
public LoopbackBrowser(string redirectUri) => _redirectUri = redirectUri;
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken ct = default)
{
// Derive the listener prefix from the redirect URI
var uri = new Uri(_redirectUri);
var prefix = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
using var listener = new HttpListener();
listener.Prefixes.Add(prefix);
try
{
listener.Start();
}
catch (Exception ex)
{
return new BrowserResult
{
ResultType = BrowserResultType.UnknownError,
Error = $"Could not start loopback listener on {prefix}: {ex.Message}"
};
}
try
{
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
}
catch (Exception ex)
{
return new BrowserResult
{
ResultType = BrowserResultType.UnknownError,
Error = $"Could not open browser: {ex.Message}"
};
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(Timeout);
try
{
var context = await listener.GetContextAsync().WaitAsync(cts.Token);
var responseBody = Encoding.UTF8.GetBytes(
"<html><body style=\"font-family:sans-serif;background:#0D1311;color:#E4EBE4;padding:40px\">" +
"<h2>Login successful</h2><p>You may close this tab.</p></body></html>");
context.Response.ContentLength64 = responseBody.Length;
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.OutputStream.WriteAsync(responseBody, cts.Token);
context.Response.OutputStream.Close();
// rawUrl already includes the redirect path (e.g. "/callback?code=..."),
// so build the full URL from the scheme://host:port base — NOT the full
// redirect URI, or the path would be doubled (".../callback/callback").
var rawUrl = context.Request.RawUrl ?? "";
var fullUri = prefix.TrimEnd('/') + rawUrl;
return new BrowserResult
{
ResultType = BrowserResultType.Success,
Response = fullUri
};
}
catch (OperationCanceledException)
{
return new BrowserResult
{
ResultType = BrowserResultType.Timeout,
Error = "Login timed out waiting for browser callback."
};
}
finally
{
listener.Stop();
}
}
}

View File

@@ -588,7 +588,8 @@ public sealed record OnlineInboxStateDto(
string ClientId,
string Scopes,
string RedirectUri,
bool SignedIn);
bool SignedIn,
int PollIntervalSeconds);
public sealed record OnlineInboxConfigInputDto(
bool Enabled,

View File

@@ -0,0 +1,121 @@
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly IOnlineLoginService _loginService;
[ObservableProperty] private bool _enabled;
[ObservableProperty] private string _apiBaseUrl = "";
[ObservableProperty] private string _authority = "";
[ObservableProperty] private string _clientId = "";
[ObservableProperty] private string _scopes = "openid offline_access";
[ObservableProperty] private string _redirectUri = "http://localhost:8765/callback";
[ObservableProperty] private int _pollIntervalSeconds = 60;
[ObservableProperty] private bool _signedIn;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
public OnlineInboxSettingsViewModel(IWorkerClient worker, IOnlineLoginService loginService)
{
_worker = worker;
_loginService = loginService;
}
public async Task LoadAsync()
{
IsBusy = true;
StatusMessage = "";
try
{
var dto = await _worker.GetOnlineInboxStateAsync();
if (dto is null)
{
StatusMessage = Loc.T("vm.onlineInbox.workerOffline");
return;
}
Enabled = dto.Enabled;
ApiBaseUrl = dto.ApiBaseUrl;
Authority = dto.Authority;
ClientId = dto.ClientId;
Scopes = dto.Scopes;
RedirectUri = dto.RedirectUri;
SignedIn = dto.SignedIn;
PollIntervalSeconds = dto.PollIntervalSeconds;
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task Save()
{
IsBusy = true;
StatusMessage = "";
try
{
await _worker.SetOnlineInboxConfigAsync(new OnlineInboxConfigInputDto(
Enabled,
ApiBaseUrl,
PollIntervalSeconds,
Authority,
ClientId,
Scopes,
RedirectUri));
StatusMessage = Loc.T("vm.onlineInbox.saved");
}
catch (Exception ex)
{
StatusMessage = Loc.T("vm.onlineInbox.saveFailed", ex.Message);
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task SignIn()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _loginService.LoginAsync(Authority, ClientId, Scopes, RedirectUri);
if (!result.Success)
{
StatusMessage = Loc.T("vm.onlineInbox.signInFailed", result.Error ?? "Unknown error");
return;
}
await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!);
SignedIn = true;
StatusMessage = Loc.T("vm.onlineInbox.signedIn");
}
catch (Exception ex)
{
StatusMessage = Loc.T("vm.onlineInbox.signInFailed", ex.Message);
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task SignOut()
{
IsBusy = true;
StatusMessage = "";
try
{
await _worker.ClearOnlineInboxAuthAsync();
SignedIn = false;
StatusMessage = Loc.T("vm.onlineInbox.signedOut");
}
catch (Exception ex)
{
StatusMessage = Loc.T("vm.onlineInbox.signOutFailed", ex.Message);
}
finally { IsBusy = false; }
}
}

View File

@@ -17,6 +17,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
@@ -25,6 +26,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
IOnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings)
{
_worker = worker;
@@ -36,6 +38,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
}
public async Task LoadAsync()
@@ -65,6 +68,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
await Prime.LoadAsync();
await OnlineInbox.LoadAsync();
}
finally { IsBusy = false; }
}

View File

@@ -261,6 +261,99 @@
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
<!-- Enable toggle + restart hint -->
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding OnlineInbox.Enabled, Mode=TwoWay}"
Content="{loc:Tr settings.onlineInbox.enabledLabel}"/>
<TextBlock Classes="meta" Text="{loc:Tr settings.onlineInbox.restartHint}"
TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<!-- Auth status section -->
<StackPanel Spacing="8">
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.statusSection}"/>
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding OnlineInbox.SignedIn}">
<Border Width="8" Height="8" CornerRadius="4"
Background="{DynamicResource StatusRunningBrush}"/>
<TextBlock Classes="body" VerticalAlignment="Center"
Text="{loc:Tr settings.onlineInbox.signedInStatus}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding !OnlineInbox.SignedIn}">
<Border Width="8" Height="8" CornerRadius="4"
Background="{DynamicResource StatusIdleBrush}"/>
<TextBlock Classes="body" VerticalAlignment="Center"
Text="{loc:Tr settings.onlineInbox.signedOutStatus}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn"
Content="{loc:Tr settings.onlineInbox.signInButton}"
Command="{Binding OnlineInbox.SignInCommand}"
IsEnabled="{Binding !OnlineInbox.IsBusy}"
IsVisible="{Binding !OnlineInbox.SignedIn}"/>
<Button Classes="btn danger"
Content="{loc:Tr settings.onlineInbox.signOutButton}"
Command="{Binding OnlineInbox.SignOutCommand}"
IsEnabled="{Binding !OnlineInbox.IsBusy}"
IsVisible="{Binding OnlineInbox.SignedIn}"/>
</StackPanel>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<!-- Config fields -->
<StackPanel Spacing="10">
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.configSection}"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.apiBaseUrlLabel}"/>
<TextBox Text="{Binding OnlineInbox.ApiBaseUrl, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.onlineInbox.apiBaseUrlPlaceholder}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.authorityLabel}"/>
<TextBox Text="{Binding OnlineInbox.Authority, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.onlineInbox.authorityPlaceholder}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.clientIdLabel}"/>
<TextBox Text="{Binding OnlineInbox.ClientId, Mode=TwoWay}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.scopesLabel}"/>
<TextBox Text="{Binding OnlineInbox.Scopes, Mode=TwoWay}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.redirectUriLabel}"/>
<TextBox Text="{Binding OnlineInbox.RedirectUri, Mode=TwoWay}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.pollIntervalLabel}"/>
<NumericUpDown Value="{Binding OnlineInbox.PollIntervalSeconds, Mode=TwoWay}"
Minimum="10" Maximum="3600" Increment="10" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
</StackPanel>
<Button Classes="btn"
Content="{loc:Tr settings.onlineInbox.saveButton}"
Command="{Binding OnlineInbox.SaveCommand}"
IsEnabled="{Binding !OnlineInbox.IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding OnlineInbox.StatusMessage}"
IsVisible="{Binding OnlineInbox.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>