Skip to content

Organization branding in the desktop editor (API + UI)#1783

Merged
richiemcilroy merged 32 commits intomainfrom
editor-organization
May 6, 2026
Merged

Organization branding in the desktop editor (API + UI)#1783
richiemcilroy merged 32 commits intomainfrom
editor-organization

Conversation

@richiemcilroy
Copy link
Copy Markdown
Member

@richiemcilroy richiemcilroy commented May 6, 2026

This PR adds organization-level brand colors (and logo updates) end-to-end for the desktop app, backed by new authenticated desktop API routes on the web app.

Greptile Summary

This PR adds end-to-end organization branding (brand colors + logo) to the desktop editor. It introduces new authenticated API routes on the web side, a shared contract package for schemas, a desktop utility layer with reactive caching and a 45-minute TTL, and UI wiring that injects brand-color swatches into every color picker across the editor.

  • API layer (root.ts, organization-branding.ts): New GET /organizations and PATCH /organizations/:id/branding endpoints backed by logo magic-byte validation, base64 decode, and metadata-merging helpers.
  • Desktop cache (organization-branding.ts): Module-level refresh deduplication, onKeyChange-driven reactive queries, and an optimistic cache write on successful PATCH confirmed by a full updateAuthPlan call.
  • Editor UI (OrganizationDropdown, BrandColorsDropdown, ConfigSidebar, et al.): New org-selector and brand-settings dialog in the editor header; brand-color swatches propagated to all color pickers across the background, gradient, captions, keyboard, and text-segment tabs.

Confidence Score: 5/5

Safe to merge — all findings are non-blocking quality observations.

Server-side auth and permission checks are correct, logo upload is validated with magic bytes and size limits, the reactive cache uses onKeyChange to keep all component instances in sync, and the Zod contract schemas are shared cleanly. All three findings are narrow and do not affect the correctness of the happy path.

apps/web/app/api/desktop/[...route]/root.ts (membership filter on the PATCH query) and apps/desktop/src/utils/organization-branding.ts (failure-path flag logic in updateOrganizationBranding).

Security Review

  • Organization existence probe (root.ts PATCH endpoint): Any authenticated user can distinguish a live organization from a non-existent/tombstoned one by probing arbitrary UUIDs — 403 means active, 404 means absent. The GET /organizations endpoint restricts to membership; the PATCH query does not. Impact is low given UUID IDs, but a membership filter before the auth check would close the gap.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/api.rs Adds OrganizationBrandColors struct and extends Organization with role, canEditBrand, iconUrl, and brandColors; all new fields use #[serde(default)] so existing cached data deserialises safely.
apps/desktop/src-tauri/src/auth.rs Adds organizations_updated_at: Option<i32> stamped on every successful org fetch; the as i32 cast mirrors existing code but will overflow in 2038.
apps/web/app/api/desktop/[...route]/root.ts Adds GET /organizations and PATCH /organizations/:id/branding; the PATCH query lacks a membership filter, enabling authenticated users to probe org existence via 404/403.
apps/web/app/api/desktop/[...route]/organization-branding.ts Validates brand colors and decodes/validates base64 logo uploads with per-MIME magic-byte checks; logic is clean and well-tested.
apps/desktop/src/utils/organization-branding.ts Implements org cache management and reactive queries; updateOrganizationBranding incorrectly triggers markOrganizationRefreshFailure with a null userId when updateAuthPlan fails after a successful optimistic write.
apps/desktop/src/routes/editor/OrganizationDropdown.tsx New org-selector dropdown with an inline brand-settings dialog; file-size and type validation mirrors server-side checks.
apps/desktop/src/routes/editor/ConfigSidebar.tsx Wires brand-color swatches into all color pickers; onKeyChange ensures selection changes propagate automatically across independent query instances.
packages/web-api-contract/src/desktop.ts Adds shared Zod schemas for brand colors, desktop org, logo update, and branding patch body; nullable colors and discriminated union for logo actions.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src-tauri/src/auth.rs:56
**`i32` Unix timestamp will overflow in 2038**

`chrono::Utc::now().timestamp()` returns `i64`, and the `as i32` cast truncates the value. The current Unix timestamp (~1.75 billion) still fits, but it will overflow and become negative on 19 January 2038. On the JS side, `hasCompleteOrganizationCache` computes `now - updatedAt * 1000 <= ORGANIZATION_CACHE_FRESH_MS` — a negative `updatedAt` would make that difference enormous, causing the cache to always appear stale and triggering a network refresh on every launch after that date. The existing `last_checked` field in `lib.rs` uses the same pattern, so this is a pre-existing codebase choice, but it's worth capturing in a tracking issue.

### Issue 2 of 3
apps/web/app/api/desktop/[...route]/root.ts:481-503
**Organization existence leaks via 404 / 403 distinction**

The branding PATCH query fetches any organization by ID regardless of whether the requester is a member. An authenticated non-member who supplies an arbitrary UUID receives a 403 when the org is active vs. a 404 when it does not exist or is tombstoned, leaking liveness. The GET `/organizations` endpoint already restricts to member/owner orgs via SQL; applying the same membership filter here before `canEditOrganizationBranding` is reached would close the gap.

### Issue 3 of 3
apps/desktop/src/utils/organization-branding.ts:452-462
**`updateAuthPlan` failure incorrectly suppresses cache refreshes**

`updateCachedOrganization` writes the server-confirmed org to the Tauri store and sets a fresh timestamp. If the subsequent `commands.updateAuthPlan()` throws, `markOrganizationRefreshFailure(userId)` is called — but `userId` is still `null` at the catch site (it is assigned inside the `try`). This suppresses auto-refreshes for 60 s even though the optimistic data is accurate. Calling `markOrganizationRefreshFailure` only when the cache update itself fails would be more precise.

Reviews (2): Last reviewed commit: "fix(web): apply organization logo upload..." | Re-trigger Greptile

@brin-security-scanner brin-security-scanner Bot added the contributor:verified Contributor passed trust analysis. label May 6, 2026
@paragon-review
Copy link
Copy Markdown

paragon-review Bot commented May 6, 2026

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

@brin-security-scanner brin-security-scanner Bot added the pr:verified PR passed security analysis. label May 6, 2026
throw new OrganizationBrandingValidationError("Invalid logo data");
}

const buffer = Buffer.from(logo.data, "base64");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor DoS hardening: Buffer.from will allocate based on the full base64 input. Since you already validate the charset, you can preflight size with Buffer.byteLength(..., "base64") and only decode if it’s within the limit.

Suggested change
const buffer = Buffer.from(logo.data, "base64");
const decodedLength = Buffer.byteLength(logo.data, "base64");
if (decodedLength === 0) {
throw new OrganizationBrandingValidationError("Logo file is empty");
}
if (decodedLength > MAX_ORGANIZATION_LOGO_BYTES) {
throw new OrganizationBrandingValidationError(
"Logo file must be less than 1MB",
);
}
const buffer = Buffer.from(logo.data, "base64");

}

const organization = DesktopOrganizationSchema.parse(response.body);
await commands.updateAuthPlan();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If updateAuthPlan() fails (network hiccup, etc) after the PATCH succeeded, this currently throws and makes the UI look like the branding update failed. Might be nicer to return the updated org and treat the refresh as best-effort.

Suggested change
await commands.updateAuthPlan();
const organization = DesktopOrganizationSchema.parse(response.body);
try {
await commands.updateAuthPlan();
const auth = (await authStore.get()) as CachedAuthStore | null;
markOrganizationRefreshSuccess(auth?.user_id ?? null);
} catch (error: unknown) {
const auth = (await authStore.get()) as CachedAuthStore | null;
markOrganizationRefreshFailure(auth?.user_id ?? null);
console.error(error);
}
return organization;

export async function encodeFileAsBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
const chunkSize = 0x8000;
let binary = "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

binary += ... in a loop can get pretty slow (quadratic-ish) for larger files. Since logos can be ~1MB, pushing chunks into an array and joining is usually cheaper.

Suggested change
let binary = "";
const chunkSize = 0x8000;
const chunks: string[] = [];
for (let index = 0; index < bytes.length; index += chunkSize) {
chunks.push(
String.fromCharCode(...bytes.subarray(index, index + chunkSize)),
);
}
return btoa(chunks.join(""));

Comment on lines +413 to +418
const organization = DesktopOrganizationSchema.parse(response.body);
await commands.updateAuthPlan();
const auth = (await authStore.get()) as CachedAuthStore | null;
markOrganizationRefreshSuccess(auth?.user_id ?? null);
return organization;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale brand colors after save

markOrganizationRefreshSuccess is called immediately after commands.updateAuthPlan() writes to the Tauri store, but before any SolidJS authStore.createQuery() instance has called auth.refetch(). Because shouldRefreshOrganizations checks hasRecentOrganizationRefresh first, every active createDesktopOrganizationsQuery instance (one in OrganizationDropdown, another in ConfigSidebar) sees the module-level timestamp as "fresh" and skips its auto-refresh effect. The result: organizations() stays stale across both instances, so the brand-color swatches fed into the gradient editor, captions tab, keyboard tab, and text-segment config all continue showing the pre-save colors until the 45-minute TTL expires.

Compare with refresh(), which explicitly awaits auth.refetch() after commands.updateAuthPlan() before marking success. The same ordering is needed here — or the onSaved callback in OrganizationDropdown should call organizationSelection.refresh() instead of only persisting the selected ID.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/organization-branding.ts
Line: 413-418

Comment:
**Stale brand colors after save**

`markOrganizationRefreshSuccess` is called immediately after `commands.updateAuthPlan()` writes to the Tauri store, but before any SolidJS `authStore.createQuery()` instance has called `auth.refetch()`. Because `shouldRefreshOrganizations` checks `hasRecentOrganizationRefresh` first, every active `createDesktopOrganizationsQuery` instance (one in `OrganizationDropdown`, another in `ConfigSidebar`) sees the module-level timestamp as "fresh" and skips its auto-refresh effect. The result: `organizations()` stays stale across both instances, so the brand-color swatches fed into the gradient editor, captions tab, keyboard tab, and text-segment config all continue showing the pre-save colors until the 45-minute TTL expires.

Compare with `refresh()`, which explicitly awaits `auth.refetch()` after `commands.updateAuthPlan()` before marking success. The same ordering is needed here — or the `onSaved` callback in `OrganizationDropdown` should call `organizationSelection.refresh()` instead of only persisting the selected ID.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +491 to +497
.from(organizations)
.leftJoin(
organizationMembers,
and(
eq(organizationMembers.organizationId, organizations.id),
eq(organizationMembers.userId, user.id),
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-atomic colors + logo update

The metadata (brand colors) update is committed to the database before applyOrganizationLogoUpdate runs. If the logo upload or removal throws — storage error, network timeout, DB write failure — the response is a 500 but the color change is already persisted. The client receives an error and may retry the full operation, potentially applying the color update a second time or getting into a state where colors are updated but the logo action was never applied. Wrapping both operations in a single transaction (or reversing the order so the logo is applied first) would prevent this partial-write window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/desktop/[...route]/root.ts
Line: 491-497

Comment:
**Non-atomic colors + logo update**

The metadata (brand colors) update is committed to the database before `applyOrganizationLogoUpdate` runs. If the logo upload or removal throws — storage error, network timeout, DB write failure — the response is a 500 but the color change is already persisted. The client receives an error and may retry the full operation, potentially applying the color update a second time or getting into a state where colors are updated but the logo action was never applied. Wrapping both operations in a single transaction (or reversing the order so the logo is applied first) would prevent this partial-write window.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +46 to +53
const ORGANIZATION_BRAND_COLOR_KEYS = [
"primary",
"secondary",
"accent",
"background",
] as const;

type OrganizationBrandColorKey = (typeof ORGANIZATION_BRAND_COLOR_KEYS)[number];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ORGANIZATION_BRAND_COLOR_KEYS and OrganizationBrandColorKey are already exported from ~/utils/organization-branding but are re-declared locally here, creating a silent drift risk if one copy is updated.

Suggested change
const ORGANIZATION_BRAND_COLOR_KEYS = [
"primary",
"secondary",
"accent",
"background",
] as const;
type OrganizationBrandColorKey = (typeof ORGANIZATION_BRAND_COLOR_KEYS)[number];
import {
ORGANIZATION_BRAND_COLOR_KEYS,
type OrganizationBrandColorKey,
} from "~/utils/organization-branding";
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/OrganizationDropdown.tsx
Line: 46-53

Comment:
`ORGANIZATION_BRAND_COLOR_KEYS` and `OrganizationBrandColorKey` are already exported from `~/utils/organization-branding` but are re-declared locally here, creating a silent drift risk if one copy is updated.

```suggestion
import {
	ORGANIZATION_BRAND_COLOR_KEYS,
	type OrganizationBrandColorKey,
} from "~/utils/organization-branding";
```

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Copy Markdown
Member Author

hey @greptileai please re-review the pr

@richiemcilroy richiemcilroy merged commit 91982c1 into main May 6, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant