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:
@@ -123,6 +123,7 @@ sealed class Program
|
|||||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||||
|
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||||
sc.AddTransient<SettingsModalViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
sc.AddTransient<MergeModalViewModel>();
|
sc.AddTransient<MergeModalViewModel>();
|
||||||
|
|||||||
@@ -63,6 +63,26 @@
|
|||||||
"daySa": "Sa",
|
"daySa": "Sa",
|
||||||
"daySu": "So"
|
"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": {
|
"inherit": {
|
||||||
"inheritedFromList": "geerbt · Liste",
|
"inheritedFromList": "geerbt · Liste",
|
||||||
"inheritedFromGlobal": "geerbt · Global",
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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." },
|
"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." },
|
||||||
|
|||||||
@@ -63,6 +63,26 @@
|
|||||||
"daySa": "Sa",
|
"daySa": "Sa",
|
||||||
"daySu": "Su"
|
"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": {
|
"inherit": {
|
||||||
"inheritedFromList": "inherited · List",
|
"inheritedFromList": "inherited · List",
|
||||||
"inheritedFromGlobal": "inherited · Global",
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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)." },
|
"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)." },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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 ClientId,
|
||||||
string Scopes,
|
string Scopes,
|
||||||
string RedirectUri,
|
string RedirectUri,
|
||||||
bool SignedIn);
|
bool SignedIn,
|
||||||
|
int PollIntervalSeconds);
|
||||||
|
|
||||||
public sealed record OnlineInboxConfigInputDto(
|
public sealed record OnlineInboxConfigInputDto(
|
||||||
bool Enabled,
|
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 WorktreesSettingsTabViewModel Worktrees { get; }
|
||||||
public FilesSettingsTabViewModel Files { get; }
|
public FilesSettingsTabViewModel Files { get; }
|
||||||
public PrimeClaudeTabViewModel Prime { get; }
|
public PrimeClaudeTabViewModel Prime { get; }
|
||||||
|
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||||
|
|
||||||
[ObservableProperty] private string _validationError = "";
|
[ObservableProperty] private string _validationError = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -25,6 +26,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
|
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
|
||||||
|
IOnlineLoginService onlineLoginService,
|
||||||
ILocalizer localizer, AppSettings appSettings)
|
ILocalizer localizer, AppSettings appSettings)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -36,6 +38,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
||||||
Files = new FilesSettingsTabViewModel(worker);
|
Files = new FilesSettingsTabViewModel(worker);
|
||||||
Prime = prime;
|
Prime = prime;
|
||||||
|
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync()
|
public async Task LoadAsync()
|
||||||
@@ -65,6 +68,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
|
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
|
||||||
|
|
||||||
await Prime.LoadAsync();
|
await Prime.LoadAsync();
|
||||||
|
await OnlineInbox.LoadAsync();
|
||||||
}
|
}
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,99 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</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>
|
</TabControl>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ public record OnlineInboxStateDto(
|
|||||||
string ClientId,
|
string ClientId,
|
||||||
string Scopes,
|
string Scopes,
|
||||||
string RedirectUri,
|
string RedirectUri,
|
||||||
bool SignedIn);
|
bool SignedIn,
|
||||||
|
int PollIntervalSeconds);
|
||||||
|
|
||||||
public record OnlineInboxConfigInput(
|
public record OnlineInboxConfigInput(
|
||||||
bool Enabled,
|
bool Enabled,
|
||||||
@@ -725,7 +726,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_onlineInboxConfig.Zitadel.ClientId,
|
_onlineInboxConfig.Zitadel.ClientId,
|
||||||
_onlineInboxConfig.Zitadel.Scopes,
|
_onlineInboxConfig.Zitadel.Scopes,
|
||||||
_onlineInboxConfig.RedirectUri,
|
_onlineInboxConfig.RedirectUri,
|
||||||
signedIn);
|
signedIn,
|
||||||
|
_onlineInboxConfig.PollIntervalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetOnlineInboxConfig(OnlineInboxConfigInput input)
|
public void SetOnlineInboxConfig(OnlineInboxConfigInput input)
|
||||||
|
|||||||
Reference in New Issue
Block a user