Skip to content

Commit 1c54533

Browse files
committed
Rebuild organization Settings with native Platform pages and gated previews
1 parent 0a1608d commit 1c54533

186 files changed

Lines changed: 21205 additions & 1507 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/implement-platform/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ description: Build, change, consolidate, remove, or review Sim Platform UI in ap
88
Start in the Sim checkout containing the requested product work. Read the [Platform contract](../../../apps/sim/platform/AGENTS.md) and [component ownership map](../../../apps/sim/platform/docs/component-ownership.md). These versioned files carry the current rules into a fresh chat. Use the [Platform README](../../../apps/sim/platform/README.md) for the local routes, fixture states, generators, and verification commands.
99

1010
1. **Establish scope on the real route.** Identify the product entry, interaction, phase, and affected consumers. When rebuilding an existing workflow, trace the old navigation target, its access gate, and the actions reachable by that viewer. Use that evidence to decide which controls belong on the new page before consulting the broader API or provider catalog. Phase 1 owns foundations and the library. Phase 2 owns working UI and transient interaction state, with examples supplied through the server fixture flag. Phase 3 connects real data and operations. Reuse the same renderer in the browser and Sim Desktop. Read the installed Next guides for the file structure or framework behavior you are changing.
11+
For organization Settings, inventory the old sidebar in order, its viewer/deployment gates and every action reachable from each section. Map old header buttons to the row or section they act on. Verify the same destination and drilldown on the fixture and authenticated routes; keep real writes outside fixture sessions.
1112
2. **Resolve design intent.** For Paper-backed work, use `$sim-paper-design` to locate the live Shared or Platform master and the product states that use it. Exclude Marcus. Preserve the key and theme roles. Treat artboard sizes as reference measurements. For code-first changes, follow the latest explicit product direction and inspect the rendered route. Identify any source you could not verify. Update the Paper canvas when the request includes Paper changes.
1213
3. **Choose or change the owner.** Start with the behavior. An action, navigation row, value picker, or disclosure has an existing keyboard and pointer contract. Different interactions may share visual roles while retaining different semantics. Use the existing family and named props for a change in size, intent, icon, or content. Put unique anatomy and its CSS beside the route component. Keep page files free of styling. Only EMCN owns the appearance of its controls. Use Base UI there for complex keyboard, floating, and dismissal behavior.
1314
4. **Trace the full change.** On a second independent feature use, promote the original implementation and migrate both callers. For consolidation, compare semantics, map every consumer to its replacement, then remove the obsolete implementation, export, variant styles, and unused registrations. For deletion, also check menus, preferences, fixture data, workbench references, and tests that existed only for the removed feature. Preserve unrelated consumers and historical Paper evidence. A new variant needs a reachable product use. Edit the source or compiler for generated foundations and recipes.

‎apps/sim/app/o/[organizationId]/settings/navigation.ts‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ import {
22
ACCOUNT_SETTINGS_ITEMS,
33
type AccountSettingsSection,
44
getSettingsSectionMeta,
5-
isOrganizationSettingsSectionAvailable,
65
ORGANIZATION_SETTINGS_GROUPS,
76
ORGANIZATION_SETTINGS_ITEMS,
87
type OrganizationSettingsFeatures,
98
type OrganizationSettingsSection,
109
parseSettingsPathSection,
11-
resolveOrganizationSectionAccess,
1210
type SettingsNavigationItem,
1311
toSettingsHeaderMeta,
1412
} from '@/components/settings/navigation'
1513
import type { SettingsHeaderMeta } from '@/components/settings/settings-header'
14+
import { organizationSupportsSettingsSection } from '@/lib/settings/section-availability'
1615

1716
/**
1817
* A section on the organization surface's settings, tagged with the plane that
@@ -84,18 +83,8 @@ export function organizationSettingsNavigation(
8483
features: OrganizationSettingsFeatures,
8584
availability: { connectedAccounts: boolean; search: boolean; knowledgeGraphs?: boolean }
8685
) {
87-
return ORGANIZATION_SETTINGS_ITEMS.filter(
88-
(item) =>
89-
(item.id !== 'knowledge-graphs' || availability.knowledgeGraphs === true) &&
90-
(item.id !== 'connected-accounts' || availability.connectedAccounts) &&
91-
((item.id !== 'search-mcp' && item.id !== 'search-slack' && item.id !== 'integrations') ||
92-
availability.search) &&
93-
resolveOrganizationSectionAccess({
94-
section: item.id,
95-
isTargetOrganizationMember: true,
96-
isTargetOrganizationAdmin: isAdmin,
97-
}) !== 'unavailable' &&
98-
isOrganizationSettingsSectionAvailable(item.id, features)
86+
return ORGANIZATION_SETTINGS_ITEMS.filter((item) =>
87+
organizationSupportsSettingsSection(item.id, features, availability, isAdmin)
9988
)
10089
}
10190

‎apps/sim/components/settings/navigation.ts‎

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import { CodeIcon, McpIcon, SlackIcon } from '@/components/icons'
3232
import type { SettingsHeaderMeta } from '@/components/settings/settings-header'
3333
import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces'
3434
import { organizationRoutes } from '@/lib/navigation/paths'
35+
import {
36+
organizationMemberSectionAccess,
37+
organizationPlanAllowsSettingsSection,
38+
} from '@/lib/settings/section-availability'
3539

3640
export type SettingsPlane = 'account' | 'selfhost' | 'workspace'
3741

@@ -1038,11 +1042,7 @@ export function resolveOrganizationSectionAccess({
10381042
isTargetOrganizationAdmin,
10391043
}: ResolveOrganizationSectionAccessOptions): OrganizationSectionAccess {
10401044
if (!isTargetOrganizationMember) return 'unavailable'
1041-
if (section === 'search-mcp' || section === 'recently-deleted' || section === 'knowledge-graphs')
1042-
return 'view'
1043-
if (section === 'members' || section === 'requests')
1044-
return isTargetOrganizationAdmin ? 'manage' : 'view'
1045-
return isTargetOrganizationAdmin ? 'manage' : 'unavailable'
1045+
return organizationMemberSectionAccess(section, isTargetOrganizationAdmin)
10461046
}
10471047

10481048
export interface OrganizationSettingsFeatures {
@@ -1084,35 +1084,7 @@ export function getOrganizationSettingsFeatures(
10841084
}
10851085
}
10861086

1087-
/**
1088-
* Applies deployment and target-organization plan gates without consulting the
1089-
* viewer's active organization.
1090-
*/
1091-
export function isOrganizationSettingsSectionAvailable(
1092-
section: OrganizationSettingsSection,
1093-
features: OrganizationSettingsFeatures
1094-
): boolean {
1095-
if (
1096-
section === 'members' ||
1097-
section === 'search-mcp' ||
1098-
section === 'knowledge-graphs' ||
1099-
section === 'recently-deleted' ||
1100-
section === 'requests'
1101-
)
1102-
return true
1103-
if (section === 'billing') return features.billingEnabled
1104-
/* Sim Search itself is enterprise on the hosted product; self-hosted gates it by flag, not by section. */
1105-
if (section === 'integrations' || section === 'search-slack')
1106-
return !features.hosted || features.hasEnterprisePlan
1107-
/**
1108-
* Access Control follows governance rather than the plan gate: its restrictions keep applying
1109-
* through a failing payment, so hiding the page that edits them would leave an organization
1110-
* governed by rules it cannot see or loosen until the invoice clears.
1111-
*/
1112-
if (section === 'access-control' && features.hosted) return features.governanceActive
1113-
if (features.hosted) return features.hasEnterprisePlan
1114-
return features.selfHosted[section] ?? false
1115-
}
1087+
export const isOrganizationSettingsSectionAvailable = organizationPlanAllowsSettingsSection
11161088

11171089
export interface WorkspacePermissionConfig {
11181090
hideSecretsTab?: boolean
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { PermissionGroupEditorCatalog } from '@/lib/api/contracts/permission-groups'
2+
import type { BlockConfig } from '@/blocks/types'
3+
import { KNOWLEDGE_CONNECTOR_OPTIONS } from '@/ee/access-control/lib/editor-registry'
4+
import {
5+
DYNAMIC_MODEL_PROVIDERS,
6+
getStaticProviderModels,
7+
NAMESPACED_MODEL_PROVIDERS,
8+
PROVIDER_DEFINITIONS,
9+
} from '@/providers/models'
10+
import { getAllProviderIds } from '@/providers/utils'
11+
import { getToolMetadata } from '@/tools/metadata'
12+
13+
/** The same editor options for a viewer's visible block universe and for the isolated design fixture. */
14+
export function projectPermissionGroupCatalog(
15+
universe: BlockConfig[]
16+
): PermissionGroupEditorCatalog {
17+
const staticModelIds = (providerId: string) =>
18+
new Set(getStaticProviderModels(providerId).map((model) => model.id))
19+
20+
return {
21+
blocks: universe.map((block) => ({
22+
type: block.type,
23+
name: block.name,
24+
description: block.description,
25+
category: block.category,
26+
hidden: Boolean(block.hideFromToolbar),
27+
sourceWorkspaceName: block.sourceWorkspaceName ?? null,
28+
tools: (block.tools?.access ?? []).map((id) => ({
29+
id,
30+
label: getToolMetadata(id)?.name ?? id,
31+
})),
32+
})),
33+
providerIds: getAllProviderIds(),
34+
dynamicProviderIds: [...DYNAMIC_MODEL_PROVIDERS],
35+
providers: Object.entries(PROVIDER_DEFINITIONS).map(([id, definition]) => {
36+
const fromStaticCatalog = staticModelIds(id)
37+
return {
38+
id,
39+
name: definition.name,
40+
namespaced: NAMESPACED_MODEL_PROVIDERS.has(id),
41+
modelPatterns: (definition.modelPatterns ?? []).map(({ source, flags }) => ({
42+
source,
43+
flags,
44+
})),
45+
models: definition.models.map((model) => ({
46+
id: model.id,
47+
inStaticCatalog: fromStaticCatalog.has(model.id),
48+
sunset: model.sunset?.status ?? null,
49+
})),
50+
}
51+
}),
52+
knowledgeConnectors: KNOWLEDGE_CONNECTOR_OPTIONS,
53+
}
54+
}

‎apps/sim/ee/access-control/lib/editor-catalog.server.ts‎

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,8 @@ import {
66
} from '@/lib/workflows/custom-blocks/operations'
77
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
88
import { withBlockVisibility } from '@/blocks/visibility/server-context'
9-
import {
10-
getAccessControlBlockUniverse,
11-
KNOWLEDGE_CONNECTOR_OPTIONS,
12-
} from '@/ee/access-control/lib/editor-registry'
13-
import {
14-
DYNAMIC_MODEL_PROVIDERS,
15-
getStaticProviderModels,
16-
NAMESPACED_MODEL_PROVIDERS,
17-
PROVIDER_DEFINITIONS,
18-
} from '@/providers/models'
19-
import { getAllProviderIds } from '@/providers/utils'
20-
import { getToolMetadata } from '@/tools/metadata'
21-
9+
import { projectPermissionGroupCatalog } from '@/ee/access-control/lib/editor-catalog-projection'
10+
import { getAccessControlBlockUniverse } from '@/ee/access-control/lib/editor-registry'
2211
/**
2312
* What the permission group editor needs from the block and provider registries, for a surface that
2413
* does not bundle them.
@@ -40,41 +29,5 @@ export async function buildPermissionGroupEditorCatalog(context: {
4029
const universe = await withBlockVisibility(visibility, () =>
4130
withCustomBlockOverlay(customBlockRows, async () => getAccessControlBlockUniverse())
4231
)
43-
const staticModelIds = (providerId: string) =>
44-
new Set(getStaticProviderModels(providerId).map((model) => model.id))
45-
46-
return {
47-
blocks: universe.map((block) => ({
48-
type: block.type,
49-
name: block.name,
50-
description: block.description,
51-
category: block.category,
52-
hidden: Boolean(block.hideFromToolbar),
53-
sourceWorkspaceName: block.sourceWorkspaceName ?? null,
54-
tools: (block.tools?.access ?? []).map((id) => ({
55-
id,
56-
label: getToolMetadata(id)?.name ?? id,
57-
})),
58-
})),
59-
providerIds: getAllProviderIds(),
60-
dynamicProviderIds: [...DYNAMIC_MODEL_PROVIDERS],
61-
providers: Object.entries(PROVIDER_DEFINITIONS).map(([id, definition]) => {
62-
const fromStaticCatalog = staticModelIds(id)
63-
return {
64-
id,
65-
name: definition.name,
66-
namespaced: NAMESPACED_MODEL_PROVIDERS.has(id),
67-
modelPatterns: (definition.modelPatterns ?? []).map(({ source, flags }) => ({
68-
source,
69-
flags,
70-
})),
71-
models: definition.models.map((model) => ({
72-
id: model.id,
73-
inStaticCatalog: fromStaticCatalog.has(model.id),
74-
sunset: model.sunset?.status ?? null,
75-
})),
76-
}
77-
}),
78-
knowledgeConnectors: KNOWLEDGE_CONNECTOR_OPTIONS,
79-
}
32+
return projectPermissionGroupCatalog(universe)
8033
}

‎apps/sim/hooks/queries/general-settings.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ export async function refreshGeneralSettings(queryClient: QueryClient): Promise<
4444
* Hook to fetch general settings.
4545
* TanStack Query is now the single source of truth for general settings.
4646
*/
47-
export function useGeneralSettings() {
47+
export function useGeneralSettings({ persistTheme = true }: { persistTheme?: boolean } = {}) {
4848
const query = useQuery(generalSettingsQuery)
4949

5050
useEffect(() => {
51-
if (query.data?.theme) syncThemeToNextThemes(query.data.theme)
52-
}, [query.data?.theme])
51+
if (persistTheme && query.data?.theme) syncThemeToNextThemes(query.data.theme)
52+
}, [persistTheme, query.data?.theme])
5353

5454
return query
5555
}
@@ -152,7 +152,7 @@ type UpdateSettingParams = {
152152
}
153153
}[keyof GeneralSettings]
154154

155-
export function useUpdateGeneralSetting() {
155+
export function useUpdateGeneralSetting({ persistTheme = true }: { persistTheme?: boolean } = {}) {
156156
const queryClient = useQueryClient()
157157

158158
return useMutation({
@@ -174,7 +174,7 @@ export function useUpdateGeneralSetting() {
174174

175175
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
176176

177-
if (key === 'theme') {
177+
if (persistTheme && key === 'theme') {
178178
syncThemeToNextThemes(value as GeneralSettings['theme'])
179179
}
180180
}
@@ -184,7 +184,7 @@ export function useUpdateGeneralSetting() {
184184
onError: (err, _variables, context) => {
185185
if (context?.previousSettings) {
186186
queryClient.setQueryData(generalSettingsKeys.settings(), context.previousSettings)
187-
syncThemeToNextThemes(context.previousSettings.theme)
187+
if (persistTheme) syncThemeToNextThemes(context.previousSettings.theme)
188188
}
189189
logger.error('Failed to update setting:', err)
190190
},

‎apps/sim/hooks/use-profile-picture-upload.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface UseProfilePictureUploadProps {
1313
currentImage?: string | null
1414
context?: 'profile-pictures' | 'workspace-logos'
1515
workspaceId?: string
16+
uploadWorkspaceLogo?: (file: File) => Promise<string>
1617
}
1718

1819
/**
@@ -25,6 +26,7 @@ export function useProfilePictureUpload({
2526
currentImage,
2627
context = 'profile-pictures',
2728
workspaceId,
29+
uploadWorkspaceLogo,
2830
}: UseProfilePictureUploadProps = {}) {
2931
const previewRef = useRef<string | null>(null)
3032
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -67,8 +69,9 @@ export function useProfilePictureUpload({
6769
async (file: File): Promise<string> => {
6870
if (context === 'workspace-logos') {
6971
if (!workspaceId) {
70-
throw new Error('workspaceId is required for workspace logo upload')
72+
throw new Error('A workspace in this organization is required to upload branding images.')
7173
}
74+
if (uploadWorkspaceLogo) return uploadWorkspaceLogo(file)
7275
const result = await uploadInternalFileSession({
7376
purpose: 'workspace_logo',
7477
workspaceId,
@@ -85,7 +88,7 @@ export function useProfilePictureUpload({
8588
logger.info(`${context} uploaded successfully: ${result.path}`)
8689
return result.path
8790
},
88-
[context, workspaceId]
91+
[context, workspaceId, uploadWorkspaceLogo]
8992
)
9093

9194
const processFile = useCallback(
@@ -154,16 +157,14 @@ export function useProfilePictureUpload({
154157
}, [])
155158

156159
/**
157-
* Restore the preview to the stored image (`currentImage`) — for a form discard
158-
* that should revert an unsaved pick/removal without clearing the saved image.
159-
* Unlike {@link handleRemove}, this does not emit `onUpload(null)`.
160+
* Restore a saved preview after discarding a draft. Defaults to the current image.
160161
*/
161-
const reset = useCallback(() => {
162+
const reset = useCallback((savedImage = currentImageRef.current) => {
162163
if (previewRef.current) {
163164
URL.revokeObjectURL(previewRef.current)
164165
previewRef.current = null
165166
}
166-
setPreviewUrl(currentImageRef.current || null)
167+
setPreviewUrl(savedImage || null)
167168
setFileName(null)
168169
if (fileInputRef.current) {
169170
fileInputRef.current.value = ''

‎apps/sim/lib/logs/time-range.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import type { TimeRange } from '@/stores/logs/filters/types'
88
* @param startDate - Optional start date (YYYY-MM-DD) for custom range
99
* @returns Date object for the start of the range, or null for 'All time'
1010
*/
11-
export function getStartDateFromTimeRange(timeRange: TimeRange, startDate?: string): Date | null {
11+
export function getStartDateFromTimeRange(
12+
timeRange: TimeRange,
13+
startDate?: string,
14+
referenceTime = Date.now()
15+
): Date | null {
1216
if (timeRange === 'All time') return null
1317

1418
if (timeRange === 'Custom range') {
@@ -20,7 +24,7 @@ export function getStartDateFromTimeRange(timeRange: TimeRange, startDate?: stri
2024
return null
2125
}
2226

23-
const now = new Date()
27+
const now = new Date(referenceTime)
2428

2529
switch (timeRange) {
2630
case 'Past 30 minutes':

0 commit comments

Comments
 (0)