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

@@ -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,