From 80a2de6c74dbe98b8daedcfa34858e503d0afb0c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 10 Jun 2026 11:02:14 +0200 Subject: [PATCH] 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) --- src/ClaudeDo.App/Program.cs | 1 + src/ClaudeDo.Localization/locales/de.json | 21 +++ src/ClaudeDo.Localization/locales/en.json | 21 +++ src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 1 + .../Interfaces/IOnlineLoginService.cs | 10 ++ .../Services/OnlineLoginService.cs | 137 ++++++++++++++++++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 3 +- .../Settings/OnlineInboxSettingsViewModel.cs | 121 ++++++++++++++++ .../Modals/SettingsModalViewModel.cs | 4 + .../Views/Modals/SettingsModalView.axaml | 93 ++++++++++++ src/ClaudeDo.Worker/Hub/WorkerHub.cs | 6 +- 11 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs create mode 100644 src/ClaudeDo.Ui/Services/OnlineLoginService.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 63d4768..992f8e1 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -123,6 +123,7 @@ sealed class Program sc.AddTransient>(sp => () => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(); + sc.AddSingleton(); sc.AddTransient(); sc.AddTransient(); sc.AddTransient(); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 027e8ab..1cc0161 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -63,6 +63,26 @@ "daySa": "Sa", "daySu": "So" }, + "onlineInbox": { + "tabHeader": "Online-Posteingang", + "enabledLabel": "Online-Posteingang-Sync aktivieren", + "restartHint": "Aktivieren oder Deaktivieren wird erst nach einem Worker-Neustart wirksam.", + "apiBaseUrlLabel": "API-Basis-URL", + "apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev", + "authorityLabel": "Zitadel-Authority (Issuer-URL)", + "authorityPlaceholder": "https://auth.example.com", + "clientIdLabel": "Client-ID", + "scopesLabel": "Scopes", + "redirectUriLabel": "Redirect-URI", + "pollIntervalLabel": "Abfrageintervall (Sekunden)", + "statusSection": "AUTH-STATUS", + "signedInStatus": "Angemeldet", + "signedOutStatus": "Nicht angemeldet", + "signInButton": "Im Browser anmelden", + "signOutButton": "Abmelden", + "configSection": "KONFIGURATION", + "saveButton": "Konfiguration speichern" + }, "inherit": { "inheritedFromList": "geerbt · Liste", "inheritedFromGlobal": "geerbt · Global", @@ -427,6 +447,7 @@ "merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, + "onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3aeacbf..cb6e1ea 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -63,6 +63,26 @@ "daySa": "Sa", "daySu": "Su" }, + "onlineInbox": { + "tabHeader": "Online Inbox", + "enabledLabel": "Enable online inbox sync", + "restartHint": "Enabling or disabling takes effect after a Worker restart.", + "apiBaseUrlLabel": "API base URL", + "apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev", + "authorityLabel": "Zitadel authority (issuer URL)", + "authorityPlaceholder": "https://auth.example.com", + "clientIdLabel": "Client ID", + "scopesLabel": "Scopes", + "redirectUriLabel": "Redirect URI", + "pollIntervalLabel": "Poll interval (seconds)", + "statusSection": "AUTH STATUS", + "signedInStatus": "Signed in", + "signedOutStatus": "Not signed in", + "signInButton": "Sign in via browser", + "signOutButton": "Sign out", + "configSection": "CONFIGURATION", + "saveButton": "Save config" + }, "inherit": { "inheritedFromList": "inherited · List", "inheritedFromGlobal": "inherited · Global", @@ -427,6 +447,7 @@ "merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, + "onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." }, diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 47a97fb..d3f27bd 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -12,6 +12,7 @@ + diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs b/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs new file mode 100644 index 0000000..ac620a1 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs @@ -0,0 +1,10 @@ +namespace ClaudeDo.Ui.Services; + +public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error); + +public interface IOnlineLoginService +{ + Task LoginAsync( + string authority, string clientId, string scope, string redirectUri, + CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Ui/Services/OnlineLoginService.cs b/src/ClaudeDo.Ui/Services/OnlineLoginService.cs new file mode 100644 index 0000000..8943e3b --- /dev/null +++ b/src/ClaudeDo.Ui/Services/OnlineLoginService.cs @@ -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 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); + } + } +} + +/// +/// IBrowser implementation: opens the system browser and captures the authorization +/// response via a loopback HttpListener on the redirect URI's host/port. +/// +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 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( + "" + + "

Login successful

You may close this tab.

"); + + 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(); + } + } +} diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 6a28bf8..10b20ab 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -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, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs new file mode 100644 index 0000000..a3b54c7 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs @@ -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; } + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 58477ae..c016fbc 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -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; } } diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index 613f4c9..4c7c095 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -261,6 +261,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + +