Skip to content

Commit dc4f41b

Browse files
committed
Tighten the Platform wiring after review
Shares the session-expiry rule and contract response parsing instead of copying them, makes the view model the wire shape, nests Settings entries with the server's answer as the default gate, and isolates fixture sections from the settings screen. Moves banners below the native titlebar, restores focus after a failed confirm, lays out gate actions in a row, and keeps zone assets out of the proxy.
1 parent 3b69b67 commit dc4f41b

36 files changed

Lines changed: 327 additions & 478 deletions

File tree

‎apps/sim/app/api/organizations/surface/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const GET = defineInternalJsonRoute({
1414
operation: organizationOperations.readSurface,
1515
rateLimit: internalRateLimits.none({
1616
reason:
17-
'Read once per Platform document, as the legacy organization layout reads it per render',
17+
'Read per Platform server render, as the legacy organization layout reads it per render',
1818
}),
1919
errorPolicy: internalOrganizationErrorPolicy,
2020
mapInput: () => ({}),

‎apps/sim/app/workspace/[workspaceId]/components/session-expired/session-expired.tsx‎

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,12 @@
11
'use client'
22

3-
import { useCallback, useEffect, useRef, useState } from 'react'
43
import { Chip, cn, useNativeSurfaceOcclusionReady } from '@sim/emcn'
5-
import { useSession } from '@/lib/auth/client'
4+
import { useSessionExpiry } from '@/lib/auth/session-expiry'
65
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
76

87
/**
98
* Cleanly logs out when the session dies while the app is mounted.
109
*
11-
* The workspace shell's auth gate is a Server Component, so it only re-evaluates
12-
* on a server render — a session that expires or is revoked mid-visit leaves the
13-
* SPA mounted and silently failing every request, with nothing to redirect it.
14-
* This watches the canonical session query for that transition instead.
15-
*
16-
* Detection keys off the transition, never off a single failed request: it
17-
* activates only once a session that was live settles to `null`. A signed-out
18-
* visitor never arms it (there was no session to lose), and `error` is excluded
19-
* so an offline blip or a 5xx can never masquerade as an expiry.
20-
*
2110
* Recovery goes through {@link recoverFromStaleSession}: an expired session's
2211
* cookies are never cleared server-side (better-auth's customSession plugin
2312
* swallows the deletion headers), and a request still carrying a session cookie
@@ -27,35 +16,10 @@ import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
2716
* would recreate the redirect loop.
2817
*/
2918
export function SessionExpired() {
30-
const { data: session, isPending, error } = useSession()
31-
const startedRef = useRef(false)
32-
const [sawSession, setSawSession] = useState(false)
33-
const [wasImpersonation, setWasImpersonation] = useState(false)
34-
const [failed, setFailed] = useState(false)
35-
36-
if (!sawSession && session?.user) {
37-
setSawSession(true)
38-
}
39-
if (!wasImpersonation && session?.session?.impersonatedBy) {
40-
setWasImpersonation(true)
41-
}
42-
43-
const expired = sawSession && !isPending && !error && !session?.user
19+
const { expired, failed, wasImpersonation, attemptRecovery } =
20+
useSessionExpiry(recoverFromStaleSession)
4421
const nativeSurfaceReady = useNativeSurfaceOcclusionReady(expired, 'takeover')
4522

46-
const attemptRecovery = useCallback(() => {
47-
setFailed(false)
48-
void recoverFromStaleSession().then((recovered) => {
49-
if (!recovered) setFailed(true)
50-
})
51-
}, [])
52-
53-
useEffect(() => {
54-
if (!expired || startedRef.current) return
55-
startedRef.current = true
56-
attemptRecovery()
57-
}, [expired, attemptRecovery])
58-
5923
if (!expired) {
6024
return null
6125
}

‎apps/sim/lib/api/client/request.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,14 @@ export async function requestJson<C extends AnyApiRouteContract>(
211211
...(input.keepalive === undefined ? {} : { keepalive: input.keepalive }),
212212
})
213213

214+
return readContractResponse(contract, response)
215+
}
216+
217+
/** Validates a contract's JSON response, or throws the `ApiClientError` its failure amounts to. */
218+
export async function readContractResponse<C extends AnyApiRouteContract>(
219+
contract: C,
220+
response: Response
221+
): Promise<ContractJsonResponse<C>> {
214222
const { parsed, raw } = await readResponseBody(response)
215223
if (!response.ok) {
216224
throw new ApiClientError({
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { useSession } from '@/lib/auth/client'
5+
6+
/**
7+
* Notices a session dying while the app is mounted, and recovers from it.
8+
*
9+
* A surface's auth gate is a Server Component, so it only re-evaluates on a server render — a
10+
* session that expires or is revoked mid-visit leaves the SPA mounted and silently failing every
11+
* request, with nothing to redirect it. This watches the canonical session query for that
12+
* transition instead.
13+
*
14+
* Detection keys off the transition, never off a single failed request: it activates only once a
15+
* session that was live settles to `null`. A signed-out visitor never arms it (there was no session
16+
* to lose), and `error` is excluded so an offline blip or a 5xx can never masquerade as an expiry.
17+
*
18+
* `recover` must keep a stable identity, and resolves false when sign-out failed and the surface
19+
* should offer a retry.
20+
*/
21+
export function useSessionExpiry(recover: () => Promise<boolean>) {
22+
const { data: session, isPending, error } = useSession()
23+
const startedRef = useRef(false)
24+
const [sawSession, setSawSession] = useState(false)
25+
const [wasImpersonation, setWasImpersonation] = useState(false)
26+
const [failed, setFailed] = useState(false)
27+
28+
if (!sawSession && session?.user) {
29+
setSawSession(true)
30+
}
31+
if (!wasImpersonation && session?.session?.impersonatedBy) {
32+
setWasImpersonation(true)
33+
}
34+
35+
const expired = sawSession && !isPending && !error && !session?.user
36+
37+
const attemptRecovery = useCallback(() => {
38+
setFailed(false)
39+
void recover().then((recovered) => {
40+
if (!recovered) setFailed(true)
41+
})
42+
}, [recover])
43+
44+
useEffect(() => {
45+
if (!expired || startedRef.current) return
46+
startedRef.current = true
47+
attemptRecovery()
48+
}, [expired, attemptRecovery])
49+
50+
return { expired, failed, wasImpersonation, attemptRecovery }
51+
}

‎apps/sim/lib/core/config/public-env.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
22

3-
/** Every `NEXT_PUBLIC_*` value currently in `process.env`. */
43
export function readPublicEnv(): Record<string, string | undefined> {
54
return Object.fromEntries(
65
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))

‎apps/sim/lib/navigation/paths.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const WORKSPACES_PATH = '/workspace'
2626
export const WORKSPACE_SETTINGS_PATH = `${WORKSPACES_PATH}?redirect=settings`
2727

2828
/** Root of the organization surface; `/o` alone resolves like {@link APP_ENTRY_PATH}. */
29-
const ORGANIZATIONS_PATH = '/o'
29+
export const ORGANIZATIONS_PATH = '/o'
3030

3131
/**
3232
* Every destination under one organization's surface, built from one place so the

‎apps/sim/lib/organizations/application/surface.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const readOrganizationSurface: OperationUseCase<
5252
resolveOrganizationSurfaceFlags(organizationId, userId),
5353
canBrowsePeople(organizationId, context.viewer.role),
5454
isSimLinksEnabled(organizationId),
55-
/** Decides which links are drawn; the pages behind them authorize for themselves. */
55+
// Decides which links are drawn; the pages behind them authorize for themselves.
5656
isPlatformAdmin(userId).catch((error: unknown) => {
5757
logger.warn('Could not resolve platform administration navigation', {
5858
error: getErrorMessage(error),

‎apps/sim/platform/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ bun run --cwd apps/sim test:platform:desktop
102102

103103
The Platform type check generates Next.js route types and checks the isolated project. The repository type check runs it alongside the existing app’s TypeScript project.
104104

105-
`bun run --cwd apps/sim test:platform:live` runs `checks/live/` against an app and database you have started, using `PLATFORM_LIVE_ORIGIN`, `PLATFORM_LIVE_EMAIL`, `PLATFORM_LIVE_PASSWORD` and `PLATFORM_LIVE_ORGANIZATION_NAME`. Supply `PLATFORM_LIVE_REPORT_PATH` to save its JSON report.
105+
`bun run --cwd apps/sim test:platform:live` runs `checks/live/` against an app and database you have started, using `PLATFORM_LIVE_ORIGIN`, `PLATFORM_LIVE_EMAIL`, `PLATFORM_LIVE_PASSWORD` and `PLATFORM_LIVE_ORGANIZATION_NAME`. The member needs build access: anyone else lands on a legacy route. Supply `PLATFORM_LIVE_REPORT_PATH` to save its JSON report.
106106

107107
The browser suite starts separate production servers with fixtures enabled and disabled. Supply `PLATFORM_REPORT_PATH` to save JSON, failure screenshots and traces. Next uses webpack for this nested app: the installed Turbopack also discovers the parent Sim project's proxy and instrumentation. Watch for that boundary when upgrading Next.
108108

‎apps/sim/platform/app/(shell)/_components/destinations.ts‎

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,26 @@ export const PLATFORM_DESTINATIONS = [
77
{ id: 'integrations', label: 'Integrations', icon: 'I070', value: '/integrations' },
88
] as const
99

10+
const [HOME, SETTINGS, INTEGRATIONS] = PLATFORM_DESTINATIONS
11+
1012
/** The legacy navigation's gates and order. Search and Benchmark stay on the legacy surface. */
1113
export function resolveLiveDestinations(organization: PlatformOrganization): ShellDestination[] {
12-
const { access, navFlags } = organization
14+
const { mothershipAvailable, canBuild, searchAccess, navFlags } = organization
1315
const routes = legacyRoutes(organization)
16+
const search = { id: 'search', label: 'Search', icon: 'I089', value: routes.search } as const
17+
const people = { id: 'people', label: 'People', icon: 'I104', value: '/people' } as const
18+
const benchmark = {
19+
id: 'benchmark',
20+
label: 'Benchmark',
21+
icon: 'I046',
22+
value: routes.benchmark,
23+
} as const
1424
return [
15-
...(access.mothershipAvailable && access.canBuild
16-
? [{ id: 'home', label: 'Home', icon: 'I104' as const, value: '/home' }]
17-
: []),
18-
...(access.searchAccess.memberScoped
19-
? [{ id: 'search', label: 'Search', icon: 'I089' as const, value: routes.search }]
20-
: []),
21-
...(navFlags.people
22-
? [{ id: 'people', label: 'People', icon: 'I104' as const, value: '/people' }]
23-
: []),
24-
...(access.searchAccess.memberScoped
25-
? [
26-
{
27-
id: 'integrations',
28-
label: 'Integrations',
29-
icon: 'I070' as const,
30-
value: '/integrations',
31-
},
32-
]
33-
: []),
34-
...(navFlags.benchmark
35-
? [{ id: 'benchmark', label: 'Benchmark', icon: 'I046' as const, value: routes.benchmark }]
36-
: []),
37-
{ id: 'settings', label: 'Settings', icon: 'I116', value: '/settings' },
25+
...(mothershipAvailable && canBuild ? [HOME] : []),
26+
...(searchAccess.memberScoped ? [search] : []),
27+
...(navFlags.people ? [people] : []),
28+
...(searchAccess.memberScoped ? [INTEGRATIONS] : []),
29+
...(navFlags.benchmark ? [benchmark] : []),
30+
SETTINGS,
3831
]
3932
}

‎apps/sim/platform/app/(shell)/_components/model.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export interface ShellData {
8383
/** So a list can tell loading and failure from empty. Absent means the data is simply there. */
8484
status?: { workspaces: ShellQueryStatus; chats: ShellQueryStatus; profile: ShellQueryStatus }
8585
workspaceCreation?: { canCreate: boolean; organizationId: string | null; reason: string | null }
86-
/** Viewer standing, access, deployment and flags. Absent outside a live session. */
86+
/** Absent outside a live session. */
8787
surface?: PlatformOrganization
8888
/** The viewer's gated navigation. Absent means the static destination lists. */
8989
destinations?: readonly ShellDestination[]

0 commit comments

Comments
 (0)