Skip to content

Commit 865c31d

Browse files
icecrasher321claude
andcommitted
fix(usage): drop the redundant organization query param, harden deep links
`organizationId` was accepted and validated on every usage query and read by no handler — all four map `params.id`, which is also the value that gets authorized. It could never widen access, but it was an API that read as though the query mattered. Removed from the shared window schema and from the hooks and export URL that were sending it. The client's custom-range guard checked that both bounds were real dates but not their ordering or their span, while this PR added a 400 for each. A bookmarked link with reversed or over-long bounds was therefore marked resolved and failed all four queries, instead of degrading to the default window the guard exists to provide. It now checks all three conditions the resolver enforces. The charts' empty state pinned itself to the clamped minimum width, which forced horizontal overflow in a narrow container — fallout from adding scrolling to the chart root, which that branch returns before reaching. It takes the container width now; the floor protects axis labels, and this branch draws none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 281688b commit 865c31d

6 files changed

Lines changed: 49 additions & 10 deletions

File tree

apps/sim/components/charts/bar-chart.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,16 @@ function BarChartComponent({
151151
<div
152152
ref={containerRef}
153153
className={cn(
154-
'flex items-center justify-center',
154+
'flex w-full items-center justify-center',
155155
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
156156
)}
157-
style={{ width, height }}
157+
/*
158+
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
159+
and pinning the empty state to it pushed a narrow container into horizontal
160+
overflow to centre two words — this branch draws no axes, so it has nothing
161+
to protect from compressing.
162+
*/
163+
style={{ height }}
158164
>
159165
<p className='text-[var(--text-muted)] text-sm'>No data</p>
160166
</div>

apps/sim/components/charts/line-chart.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,16 @@ function LineChartComponent({
208208
return (
209209
<div
210210
className={cn(
211-
'flex items-center justify-center',
211+
'flex w-full items-center justify-center',
212212
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
213213
)}
214-
style={{ width, height }}
214+
/*
215+
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
216+
and pinning the empty state to it pushed a narrow container into horizontal
217+
overflow to centre two words — this branch draws no axes, so it has nothing
218+
to protect from compressing.
219+
*/
220+
style={{ height }}
215221
>
216222
<p className='text-[var(--text-muted)] text-sm'>No data</p>
217223
</div>

apps/sim/ee/organization-usage/components/usage-monitoring.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,9 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
173173
}
174174

175175
const handleExport = async () => {
176+
// The organization is the path segment below; the query no longer carries a
177+
// second copy of it.
176178
const params = new URLSearchParams({
177-
organizationId,
178179
preset: window.preset,
179180
timezone: window.timezone,
180181
})

apps/sim/ee/organization-usage/hooks/use-usage-window.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import { useMemo } from 'react'
44
import { useQueryStates } from 'nuqs'
5-
import type { UsageWindowPreset } from '@/lib/api/contracts/organization-usage'
5+
import {
6+
MAX_CUSTOM_RANGE_DAYS,
7+
type UsageWindowPreset,
8+
} from '@/lib/api/contracts/organization-usage'
69
import { formatDateShort } from '@/lib/core/utils/date-display'
710
import { getBrowserTimezone } from '@/lib/core/utils/timezone'
811
import { DEFAULT_USAGE_PRESET, PERIOD_LABELS } from '@/ee/organization-usage/constants'
@@ -12,6 +15,8 @@ import {
1215
} from '@/ee/organization-usage/search-params'
1316
import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys'
1417

18+
const DAY_MS = 24 * 60 * 60 * 1000
19+
1520
/** A `YYYY-MM-DD` that survives a calendar round-trip, matching the contract's rule. */
1621
function isCalendarDate(value: string | null): value is string {
1722
if (!value) return false
@@ -20,6 +25,23 @@ function isCalendarDate(value: string | null): value is string {
2025
return new Date(`${datePart}T00:00:00.000Z`).toISOString().slice(0, 10) === datePart
2126
}
2227

28+
/**
29+
* Every rule the window resolver enforces, checked here too.
30+
*
31+
* The server refuses an unreal date, an inverted pair, and a span past the cap — each
32+
* as a 400. A deep link carrying any of them would otherwise be marked "resolved" and
33+
* fail all four queries on the page, which is a worse outcome than the fallback this
34+
* guard exists to provide. Duplicated deliberately, and narrowly: these are the three
35+
* conditions that turn a link into an error rather than into different data.
36+
*/
37+
function isUsableCustomRange(start: string | null, end: string | null): boolean {
38+
if (!isCalendarDate(start) || !isCalendarDate(end)) return false
39+
const from = new Date(`${start.slice(0, 10)}T00:00:00.000Z`).getTime()
40+
const to = new Date(`${end.slice(0, 10)}T00:00:00.000Z`).getTime()
41+
if (to < from) return false
42+
return Math.round((to - from) / DAY_MS) + 1 <= MAX_CUSTOM_RANGE_DAYS
43+
}
44+
2345
/**
2446
* The panel's URL state, resolved into the window every query is keyed on.
2547
*

apps/sim/hooks/queries/organization-usage.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function useOrganizationUsageSummary(
3535
queryFn: ({ signal }): Promise<OrganizationUsageSummary> =>
3636
requestJson(getOrganizationUsageSummaryContract, {
3737
params: { id: organizationId as string },
38-
query: { organizationId: organizationId as string, ...window },
38+
query: { ...window },
3939
signal,
4040
}),
4141
enabled: Boolean(organizationId),
@@ -73,7 +73,6 @@ export function useOrganizationUsageBreakdown(
7373
requestJson(getOrganizationUsageBreakdownContract, {
7474
params: { id: organizationId as string },
7575
query: {
76-
organizationId: organizationId as string,
7776
...window,
7877
dimension,
7978
limit,
@@ -99,7 +98,6 @@ export function useOrganizationUsageEvents(
9998
requestJson(listOrganizationUsageEventsContract, {
10099
params: { id: organizationId as string },
101100
query: {
102-
organizationId: organizationId as string,
103101
...window,
104102
...(sources.length ? { source: sources } : {}),
105103
limit: EVENTS_PAGE_SIZE,

apps/sim/lib/api/contracts/organization-usage.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,13 @@ function usageLimitSchema(max: number, fallback: number) {
8383
* windows — a mismatch here is how the tiles and the event log would disagree.
8484
*/
8585
const organizationUsageWindowQuerySchema = z.object({
86-
organizationId: organizationIdSchema,
86+
/*
87+
No `organizationId` here. The organization is the path parameter, and that is the
88+
one every handler authorizes and reads. Accepting a second copy in the query meant
89+
a request could name one organization and be answered about another — not an
90+
authorization hole, since `params.id` is what gets checked, but an API that reads
91+
as though the query mattered.
92+
*/
8793
preset: usageWindowPresetSchema,
8894
startDate: isoDateSchema,
8995
endDate: isoDateSchema,

0 commit comments

Comments
 (0)