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:
@@ -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>
|
||||
|
||||
10
src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs
Normal file
10
src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs
Normal 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);
|
||||
}
|
||||
137
src/ClaudeDo.Ui/Services/OnlineLoginService.cs
Normal file
137
src/ClaudeDo.Ui/Services/OnlineLoginService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user