fix(): Maximize button hides the window instead of maximizing

This commit is contained in:
mika kuns
2026-06-15 15:11:49 +02:00
21 changed files with 538 additions and 56 deletions

View File

@@ -24,7 +24,28 @@ Sync directions (each one-way per entity → no conflict resolution needed):
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the - **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
desktop pulls down and then owns. desktop pulls down and then owns.
Single user. Both the desktop and the web client authenticate as the **same Zitadel user**. Single user today. Both the desktop and the web client authenticate as the **same Zitadel
user**.
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
multiple users **without enforcing isolation client-side** — the server remains the
authority that scopes every request by the token's `sub`. When the server goes multi-user it
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS`
env var is gone). The access token carries the role in the claim
`urn:zitadel:iam:org:project:roles` (or the project-scoped variant
`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g.
`{ "user": { "<orgId>": "<orgDomain>" } }`. The desktop OIDC client
(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued
after login/refresh includes the claim automatically — no extra scopes are needed.
Granting/revoking access is purely a Zitadel role grant, nothing app-side.
## 2. Idle backlog definition (desktop side) ## 2. Idle backlog definition (desktop side)
@@ -68,9 +89,12 @@ All task writes are idempotent upserts keyed on id.
## 4. Endpoints ## 4. Endpoints
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`). All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
Missing/invalid/expired → `401`. No anonymous access (imported tasks can trigger code carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
execution on the user's machine). token without the role → `401`. No anonymous access (imported tasks can trigger code
execution on the user's machine). The desktop client treats a `401` as: force a
refresh-token exchange and retry once; if a freshly issued token is still rejected, it
surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again.
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed > **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)` > `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
@@ -80,13 +104,17 @@ execution on the user's machine).
| Method & path | Caller | Body | Response | | Method & path | Caller | Body | Response |
|---|---|---|---| |---|---|---|---|
| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` | | `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
| `GET /lists` | web | — | `200 [{ "id", "name" }]` | | `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) | | `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` | | `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` | | `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) | | `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` | | `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
and ignores pulled tasks owned by a different user; the server should derive/validate it from
the token rather than trust the client value.
Semantics: Semantics:

View File

@@ -447,7 +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}" }, "onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "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." },

View File

@@ -447,7 +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}" }, "onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "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)." },

View File

@@ -1,6 +1,6 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error); public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
public interface IOnlineLoginService public interface IOnlineLoginService
{ {

View File

@@ -34,7 +34,13 @@ public sealed class OnlineLoginService : IOnlineLoginService
return new OnlineLoginResult(false, null, return new OnlineLoginResult(false, null,
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it."); "No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
return new OnlineLoginResult(true, result.RefreshToken, null); // Early heads-up: if the access token lacks the "user" project role the server will
// reject sync with a 401. Login still succeeds; surface this as a warning, not an error.
var warning = ZitadelTokenInspector.HasUserRole(result.AccessToken)
? null
: "missing-user-role";
return new OnlineLoginResult(true, result.RefreshToken, null, warning);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@@ -0,0 +1,64 @@
using System;
using System.Text.Json;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Minimal, dependency-free inspection of a Zitadel JWT access token. Used to warn early when
/// a freshly issued token lacks the "user" project role (the server otherwise rejects sync
/// with a 401). The server remains the source of truth — this check fails open.
/// </summary>
public static class ZitadelTokenInspector
{
private const string ProjectRolesClaim = "urn:zitadel:iam:org:project:roles";
private const string ProjectRolesClaimPrefix = "urn:zitadel:iam:org:project:";
private const string ProjectRolesClaimSuffix = ":roles";
private const string UserRole = "user";
/// <summary>
/// Returns true if the access token carries the "user" role in either the generic or
/// project-scoped Zitadel roles claim. Returns true (fail-open) if the token is absent or
/// cannot be parsed — never block login on a decode hiccup.
/// </summary>
public static bool HasUserRole(string? accessToken)
{
if (string.IsNullOrWhiteSpace(accessToken))
return true;
var parts = accessToken.Split('.');
if (parts.Length < 2)
return true;
try
{
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
foreach (var claim in doc.RootElement.EnumerateObject())
{
if (claim.Name != ProjectRolesClaim &&
!(claim.Name.StartsWith(ProjectRolesClaimPrefix, StringComparison.Ordinal) &&
claim.Name.EndsWith(ProjectRolesClaimSuffix, StringComparison.Ordinal)))
continue;
if (claim.Value.ValueKind == JsonValueKind.Object &&
claim.Value.TryGetProperty(UserRole, out _))
return true;
}
return false;
}
catch
{
return true;
}
}
private static byte[] Base64UrlDecode(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4)
{
case 2: s += "=="; break;
case 3: s += "="; break;
}
return Convert.FromBase64String(s);
}
}

View File

@@ -92,7 +92,9 @@ public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!); await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!);
SignedIn = true; SignedIn = true;
StatusMessage = Loc.T("vm.onlineInbox.signedIn"); StatusMessage = result.Warning == "missing-user-role"
? Loc.T("vm.onlineInbox.signedInNoRole")
: Loc.T("vm.onlineInbox.signedIn");
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -22,7 +22,7 @@
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/> <KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/> <KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Grid RowDefinitions="36,Auto,*,22"> <Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
<!-- Custom title bar --> <!-- Custom title bar -->
<Border Grid.Row="0" <Border Grid.Row="0"
Background="{DynamicResource DeepBrush}" Background="{DynamicResource DeepBrush}"

View File

@@ -27,6 +27,8 @@ public partial class MainWindow : Window
base.OnPropertyChanged(change); base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty) if (change.Property == WindowStateProperty)
UpdateMaxIcon(); UpdateMaxIcon();
if (change.Property == OffScreenMarginProperty)
RootGrid.Margin = OffScreenMargin;
} }
private void UpdateMaxIcon() private void UpdateMaxIcon()

View File

@@ -2,19 +2,26 @@ using System.Text.Json.Serialization;
namespace ClaudeDo.Worker.Online; namespace ClaudeDo.Worker.Online;
// OwnerId carries the resource owner's Zitadel subject (sub). It is nullable and optional so
// the contract stays multi-user-ready without changing single-user behavior: today the desktop
// stamps it on push and defensively ignores pulled tasks owned by a different user, while the
// server remains the authority that scopes data by the token's sub.
public sealed record RemoteList( public sealed record RemoteList(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string Name); [property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
public sealed record RemoteTask( public sealed record RemoteTask(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("listId")] string ListId, [property: JsonPropertyName("listId")] string ListId,
[property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string? Description, [property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt); [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
public sealed record MirrorTask( public sealed record MirrorTask(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("listId")] string ListId, [property: JsonPropertyName("listId")] string ListId,
[property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string? Description); [property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("ownerId")] string? OwnerId = null);

View File

@@ -3,4 +3,11 @@ namespace ClaudeDo.Worker.Online.Interfaces;
public interface IOnlineAuthProvider public interface IOnlineAuthProvider
{ {
Task<string?> GetAccessTokenAsync(CancellationToken ct = default); Task<string?> GetAccessTokenAsync(CancellationToken ct = default);
/// <summary>
/// Gets an access token, optionally dropping any cached token first so a fresh
/// (role-bearing) token is minted via the refresh-token grant. Used to recover from a
/// 401 caused by a stale token issued before role assertion was enabled.
/// </summary>
Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default);
} }

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
namespace ClaudeDo.Worker.Online;
/// <summary>
/// Minimal, dependency-free reader for a JWT access token's payload claims. Used to resolve the
/// current user's Zitadel subject (<c>sub</c>) so sync payloads can be stamped with an owner.
/// Never throws — returns null when the token is absent or cannot be parsed.
/// </summary>
public static class JwtClaims
{
/// <summary>
/// Returns the <c>sub</c> claim of the JWT, or null if the token is absent/unparseable or
/// carries no subject.
/// </summary>
public static string? GetSubject(string? jwt)
{
if (string.IsNullOrWhiteSpace(jwt))
return null;
var parts = jwt.Split('.');
if (parts.Length < 2)
return null;
try
{
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
if (doc.RootElement.TryGetProperty("sub", out var sub) &&
sub.ValueKind == JsonValueKind.String)
return sub.GetString();
return null;
}
catch
{
return null;
}
}
private static byte[] Base64UrlDecode(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4)
{
case 2: s += "=="; break;
case 3: s += "="; break;
}
return Convert.FromBase64String(s);
}
}

View File

@@ -1,3 +1,5 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using ClaudeDo.Worker.Online.Interfaces; using ClaudeDo.Worker.Online.Interfaces;
@@ -5,6 +7,10 @@ namespace ClaudeDo.Worker.Online;
public sealed class OnlineInboxApiClient : IOnlineInboxApi public sealed class OnlineInboxApiClient : IOnlineInboxApi
{ {
internal const string MissingRoleMessage =
"Account has no access (missing 'user' role in Zitadel). " +
"Grant the 'user' role for this account in the ClaudeDo project, then sign in again.";
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly IOnlineAuthProvider _auth; private readonly IOnlineAuthProvider _auth;
@@ -31,54 +37,68 @@ public sealed class OnlineInboxApiClient : IOnlineInboxApi
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default) public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
{ {
using var req = await BuildAsync(HttpMethod.Put, "lists", lists, ct); using var resp = await SendAsync(HttpMethod.Put, "lists", lists, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
} }
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default) public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
{ {
using var req = await BuildAsync(HttpMethod.Get, "tasks?imported=false", null, ct); using var resp = await SendAsync(HttpMethod.Get, "tasks?imported=false", null, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct); var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
return result ?? []; return result ?? [];
} }
public async Task MarkImportedAsync(string id, CancellationToken ct = default) public async Task MarkImportedAsync(string id, CancellationToken ct = default)
{ {
using var req = await BuildAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct); using var resp = await SendAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
} }
public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default) public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default)
{ {
using var req = await BuildAsync(HttpMethod.Put, "tasks/mirror", tasks, ct); using var resp = await SendAsync(HttpMethod.Put, "tasks/mirror", tasks, ct);
using var resp = await _http.SendAsync(req, ct);
await EnsureSuccessAsync(resp, ct);
} }
private async Task<HttpRequestMessage> BuildAsync( /// <summary>
HttpMethod method, /// Sends an authenticated request. On a 401, forces a fresh (role-bearing) token via the
string path, /// refresh-token grant and retries once; if a fresh token is still rejected, throws an
object? body, /// <see cref="OnlineInboxException"/> with <see cref="MissingRoleMessage"/>.
CancellationToken ct) /// </summary>
private async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{ {
var token = await _auth.GetAccessTokenAsync(ct); var resp = await SendOnceAsync(method, path, body, forceRefresh: false, ct);
var req = new HttpRequestMessage(method, path);
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
resp.Dispose();
resp = await SendOnceAsync(method, path, body, forceRefresh: true, ct);
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
{
resp.Dispose();
throw new OnlineInboxException(401, MissingRoleMessage);
}
if (!resp.IsSuccessStatusCode)
{
var status = (int)resp.StatusCode;
var errBody = await resp.Content.ReadAsStringAsync(ct);
resp.Dispose();
throw new OnlineInboxException(status, $"Online Inbox API error {status}: {errBody}");
}
return resp;
}
private async Task<HttpResponseMessage> SendOnceAsync(
HttpMethod method, string path, object? body, bool forceRefresh, CancellationToken ct)
{
var token = await _auth.GetAccessTokenAsync(forceRefresh, ct);
using var req = new HttpRequestMessage(method, path);
if (token is not null) if (token is not null)
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null) if (body is not null)
req.Content = JsonContent.Create(body); req.Content = JsonContent.Create(body);
return req; return await _http.SendAsync(req, ct);
}
private static async Task EnsureSuccessAsync(HttpResponseMessage resp, CancellationToken ct)
{
if (resp.IsSuccessStatusCode) return;
var body = await resp.Content.ReadAsStringAsync(ct);
throw new OnlineInboxException((int)resp.StatusCode,
$"Online Inbox API error {(int)resp.StatusCode}: {body}");
} }
} }

View File

@@ -42,6 +42,12 @@ public sealed class OnlineSyncService : BackgroundService
{ {
return; return;
} }
catch (OnlineInboxException ex) when (ex.StatusCode == 401)
{
_logger.LogError(
"OnlineSyncService: {Message} Sync is paused until you sign in again with an authorized account.",
ex.Message);
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval"); _logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
@@ -67,6 +73,10 @@ public sealed class OnlineSyncService : BackgroundService
return; return;
} }
// Resolve the current user's Zitadel subject so sync payloads carry an owner and pulls
// can be guarded. Null today (single user / server derives it from the token).
var ownerId = JwtClaims.GetSubject(token);
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var tasks = new TaskRepository(ctx); var tasks = new TaskRepository(ctx);
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
@@ -75,6 +85,15 @@ public sealed class OnlineSyncService : BackgroundService
var unimported = await _api.GetUnimportedTasksAsync(ct); var unimported = await _api.GetUnimportedTasksAsync(ct);
foreach (var remote in unimported) foreach (var remote in unimported)
{ {
// Multi-user guard: never import a task explicitly owned by a different user.
// Unowned tasks (ownerId == null) stay importable so single-user behavior is intact.
if (ownerId is not null && remote.OwnerId is not null && remote.OwnerId != ownerId)
{
_logger.LogWarning(
"OnlineSyncService: remote task {Id} is owned by another user; skipping", remote.Id);
continue;
}
var existing = await tasks.GetByIdAsync(remote.Id, ct); var existing = await tasks.GetByIdAsync(remote.Id, ct);
if (existing is not null) if (existing is not null)
{ {
@@ -109,13 +128,15 @@ public sealed class OnlineSyncService : BackgroundService
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title); _logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
} }
// Step 2: push full list catalog. // Step 2: push full list catalog, stamped with the owner.
var allLists = await lists.GetAllAsync(ct); var allLists = await lists.GetAllAsync(ct);
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList(); var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name, ownerId)).ToList();
await _api.PutListsAsync(remoteLists, ct); await _api.PutListsAsync(remoteLists, ct);
// Step 3: push current Idle backlog mirror. // Step 3: push current Idle backlog mirror, stamped with the owner.
var mirror = await OnlineBacklog.CurrentAsync(tasks, ct); var mirror = (await OnlineBacklog.CurrentAsync(tasks, ct))
.Select(m => m with { OwnerId = ownerId })
.ToList();
await _api.PutMirrorAsync(mirror, ct); await _api.PutMirrorAsync(mirror, ct);
} }
} }

View File

@@ -18,4 +18,7 @@ public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default) public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
=> Task.FromResult(_token); => Task.FromResult(_token);
public Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
=> Task.FromResult(_token);
} }

View File

@@ -20,6 +20,9 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
// Cached access token state. // Cached access token state.
private string? _cachedAccessToken; private string? _cachedAccessToken;
private DateTimeOffset _cacheExpiry; private DateTimeOffset _cacheExpiry;
// The refresh token that minted the cached access token. When the stored refresh token
// changes (sign-out, or signing in as a different user), the cache is no longer valid.
private string? _refreshTokenUsed;
// Cached token endpoint URL (discovered once). // Cached token endpoint URL (discovered once).
private string? _tokenEndpoint; private string? _tokenEndpoint;
@@ -36,22 +39,33 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
_logger = logger; _logger = logger;
} }
public async Task<string?> GetAccessTokenAsync(CancellationToken ct = default) public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
=> GetAccessTokenAsync(false, ct);
public async Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
{ {
// Fast path: check cache without acquiring the lock. var refreshToken = _tokenStore.Read();
if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
// Fast path: cached token is valid, not forced, and was minted from the still-current
// refresh token (i.e. the signed-in user hasn't changed).
if (IsCacheUsable(forceRefresh, refreshToken))
return _cachedAccessToken; return _cachedAccessToken;
await _lock.WaitAsync(ct); await _lock.WaitAsync(ct);
try try
{ {
// Re-check inside the lock (double-checked locking). // Re-read + re-check inside the lock (double-checked locking).
if (_cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry) refreshToken = _tokenStore.Read();
if (IsCacheUsable(forceRefresh, refreshToken))
return _cachedAccessToken; return _cachedAccessToken;
var refreshToken = _tokenStore.Read(); // Drop any stale access token so a fresh one is minted for the current user.
_cachedAccessToken = null;
_cacheExpiry = default;
if (refreshToken is null) if (refreshToken is null)
{ {
_refreshTokenUsed = null;
_logger.LogDebug("No refresh token stored; skipping token refresh."); _logger.LogDebug("No refresh token stored; skipping token refresh.");
return null; return null;
} }
@@ -64,6 +78,12 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
} }
} }
private bool IsCacheUsable(bool forceRefresh, string? storedRefreshToken) =>
!forceRefresh
&& _cachedAccessToken is not null
&& DateTimeOffset.UtcNow < _cacheExpiry
&& storedRefreshToken == _refreshTokenUsed;
private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct) private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct)
{ {
var tokenEndpoint = await GetTokenEndpointAsync(ct); var tokenEndpoint = await GetTokenEndpointAsync(ct);
@@ -113,15 +133,19 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
} }
// If Zitadel rotated the refresh token, persist the new one. // If Zitadel rotated the refresh token, persist the new one.
var persistedRefreshToken = refreshToken;
if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken) if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken)
{ {
_logger.LogDebug("Refresh token rotated; persisting new token."); _logger.LogDebug("Refresh token rotated; persisting new token.");
_tokenStore.Save(tokenResponse.RefreshToken); _tokenStore.Save(tokenResponse.RefreshToken);
persistedRefreshToken = tokenResponse.RefreshToken;
} }
// Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero). // Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero).
// Remember which refresh token it was minted from so the cache invalidates on a user switch.
_cachedAccessToken = tokenResponse.AccessToken; _cachedAccessToken = tokenResponse.AccessToken;
_cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60); _cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
_refreshTokenUsed = persistedRefreshToken;
return _cachedAccessToken; return _cachedAccessToken;
} }

View File

@@ -0,0 +1,62 @@
using System;
using System.Text;
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests.Services;
public class ZitadelTokenInspectorTests
{
// Builds a fake JWT (header.payload.signature) carrying the given JSON payload.
private static string MakeToken(string payloadJson)
{
static string B64Url(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig";
}
[Fact]
public void HasUserRole_True_ForGenericRolesClaim()
{
var token = MakeToken(
"{\"urn:zitadel:iam:org:project:roles\":{\"user\":{\"org1\":\"example.com\"}}}");
Assert.True(ZitadelTokenInspector.HasUserRole(token));
}
[Fact]
public void HasUserRole_True_ForProjectScopedRolesClaim()
{
var token = MakeToken(
"{\"urn:zitadel:iam:org:project:376787351902355727:roles\":{\"user\":{\"org1\":\"example.com\"}}}");
Assert.True(ZitadelTokenInspector.HasUserRole(token));
}
[Fact]
public void HasUserRole_False_WhenRoleMissing()
{
var token = MakeToken(
"{\"urn:zitadel:iam:org:project:roles\":{\"admin\":{\"org1\":\"example.com\"}}}");
Assert.False(ZitadelTokenInspector.HasUserRole(token));
}
[Fact]
public void HasUserRole_False_WhenNoRolesClaim()
{
var token = MakeToken("{\"sub\":\"123\",\"email\":\"a@b.c\"}");
Assert.False(ZitadelTokenInspector.HasUserRole(token));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-jwt")]
[InlineData("only.two")]
public void HasUserRole_FailsOpen_ForUnparseableInput(string? token)
{
// Cannot decide -> fail open (server remains the source of truth).
Assert.True(ZitadelTokenInspector.HasUserRole(token));
}
}

View File

@@ -0,0 +1,40 @@
using System.Text;
using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Tests.Online;
public sealed class JwtClaimsTests
{
// Builds a fake JWT (header.payload.signature) carrying the given JSON payload.
internal static string MakeToken(string payloadJson)
{
static string B64Url(string s) =>
Convert.ToBase64String(Encoding.UTF8.GetBytes(s))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig";
}
[Fact]
public void GetSubject_ReturnsSub()
{
var token = MakeToken("{\"sub\":\"user-123\",\"email\":\"a@b.c\"}");
Assert.Equal("user-123", JwtClaims.GetSubject(token));
}
[Fact]
public void GetSubject_Null_WhenNoSub()
{
var token = MakeToken("{\"email\":\"a@b.c\"}");
Assert.Null(JwtClaims.GetSubject(token));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-jwt")]
[InlineData("opaque.token")]
public void GetSubject_Null_ForUnparseableInput(string? token)
{
Assert.Null(JwtClaims.GetSubject(token));
}
}

View File

@@ -130,6 +130,52 @@ public sealed class OnlineInboxApiClientTests
Assert.Equal(401, ex.StatusCode); Assert.Equal(401, ex.StatusCode);
} }
[Fact]
public async Task Unauthorized_RetriesOnceWithForcedRefresh_ThenThrowsMissingRole()
{
var (client, handler) = Build();
handler.ResponseStatus = HttpStatusCode.Unauthorized;
handler.ResponseBody = "Unauthorized";
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
() => client.PutListsAsync([]));
Assert.Equal(401, ex.StatusCode);
Assert.Equal(OnlineInboxApiClient.MissingRoleMessage, ex.Message);
// Original attempt + one forced-refresh retry.
Assert.Equal(2, handler.Requests.Count);
}
[Fact]
public async Task Unauthorized_ThenSuccessOnRetry_Succeeds()
{
var handler = new SequenceHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/api/") };
var client = new OnlineInboxApiClient(http, new StaticTokenAuthProvider("test-token"));
await client.PutListsAsync([]);
Assert.Equal(2, handler.Requests.Count);
}
private sealed class SequenceHandler : HttpMessageHandler
{
private readonly Queue<HttpStatusCode> _statuses;
public List<HttpRequestMessage> Requests { get; } = new();
public SequenceHandler(params HttpStatusCode[] statuses) => _statuses = new(statuses);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add(request);
var status = _statuses.Count > 0 ? _statuses.Dequeue() : HttpStatusCode.OK;
return Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
});
}
}
[Fact] [Fact]
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode() public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
{ {

View File

@@ -209,6 +209,76 @@ public sealed class OnlineSyncServiceTests : IDisposable
Assert.Equal(0, api.CallCount); Assert.Equal(0, api.CallCount);
} }
// ---- multi-user: owner stamping + guard ----
[Fact]
public async Task Tick_StampsOwnerId_OnPushedListsAndMirror()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
await tasks.AddAsync(new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
var token = JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}");
var api = new FakeApi();
var svc = BuildService(api, token);
await svc.TickAsync(CancellationToken.None);
Assert.All(api.ReceivedLists, l => Assert.Equal("owner-1", l.OwnerId));
Assert.NotEmpty(api.ReceivedMirror);
Assert.All(api.ReceivedMirror, m => Assert.Equal("owner-1", m.OwnerId));
}
[Fact]
public async Task Tick_SkipsRemoteTask_OwnedByAnotherUser()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var foreignId = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks =
[
new RemoteTask(foreignId, listId, "Theirs", null, DateTimeOffset.UtcNow, OwnerId: "owner-2"),
],
};
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
await svc.TickAsync(CancellationToken.None);
Assert.Null(await tasks.GetByIdAsync(foreignId));
Assert.DoesNotContain(foreignId, api.MarkedImported);
}
[Fact]
public async Task Tick_Imports_OwnTask_And_UnownedTask()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var mine = Guid.NewGuid().ToString();
var unowned = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks =
[
new RemoteTask(mine, listId, "Mine", null, DateTimeOffset.UtcNow, OwnerId: "owner-1"),
new RemoteTask(unowned, listId, "Unowned", null, DateTimeOffset.UtcNow),
],
};
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
await svc.TickAsync(CancellationToken.None);
Assert.NotNull(await tasks.GetByIdAsync(mine));
Assert.NotNull(await tasks.GetByIdAsync(unowned));
}
// ---- already-imported task on server ---- // ---- already-imported task on server ----
[Fact] [Fact]

View File

@@ -152,6 +152,37 @@ public sealed class ZitadelAuthProviderTests : IDisposable
Assert.Equal(2, handler.Requests.Count); Assert.Equal(2, handler.Requests.Count);
} }
[Fact]
public async Task ChangedRefreshToken_InvalidatesCache_AndRefreshesForNewUser()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("admin-refresh");
// First user (admin): discovery + token.
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("admin-access", expiresIn: 3600));
var adminToken = await provider.GetAccessTokenAsync();
Assert.Equal("admin-access", adminToken);
// Re-sign-in as a different user writes a new refresh token to the store.
store.Save("normal-refresh");
// Even though the cached admin token is still within its expiry window, the changed
// refresh token must force a new exchange (no second discovery — it's cached).
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("normal-access", expiresIn: 3600));
var normalToken = await provider.GetAccessTokenAsync();
Assert.Equal("normal-access", normalToken);
Assert.Equal(3, handler.Requests.Count); // discovery + admin token + normal token
}
[Fact] [Fact]
public async Task RotatedRefreshToken_IsPersisted() public async Task RotatedRefreshToken_IsPersisted()
{ {