| author | |
| committer | |
| log | 28ce9d1c1751d70c869e3de3287b96d5e28fc845 |
| tree | 815ff97b068e373f6c6ff5705ed5a35f0f253a20 |
| parent | e9c3d950f023130791d8ac5fe7aa0992e3430738 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>162 files changed, 8378 insertions(+), 1114 deletions(-)
AGENTS.md+1| ... | ... | @@ -150,6 +150,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ |
| 150 | 150 | ## Taste |
| 151 | 151 | |
| 152 | 152 | - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. |
| 153 | - Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method. | |
| 153 | 154 | - Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md). |
| 154 | 155 | - `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md). |
| 155 | 156 | - Inferred types over annotations. `any` is the enemy. |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+114-1| ... | ... | @@ -25,7 +25,14 @@ vi.mock("react-native", () => ({ |
| 25 | 25 | Platform: { OS: "ios" }, |
| 26 | 26 | })); |
| 27 | 27 | |
| 28 | vi.mock("../cloud/linkEnvironment", () => ({ | |
| 28 | vi.mock("expo-constants", () => ({ | |
| 29 | default: { expoConfig: {} }, | |
| 30 | })); | |
| 31 | ||
| 32 | vi.mock("expo-device", () => ({})); | |
| 33 | ||
| 34 | vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({ | |
| 35 | ...(await importOriginal<typeof import("../cloud/linkEnvironment")>()), | |
| 29 | 36 | linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), |
| 30 | 37 | })); |
| 31 | 38 | |
| ... | ... | @@ -78,6 +85,7 @@ describe("liveActivityPreferences", () => { |
| 78 | 85 | previousEnabled: true, |
| 79 | 86 | clerkToken: "clerk-token", |
| 80 | 87 | connections: [connection], |
| 88 | canConfigureEnvironment: () => true, | |
| 81 | 89 | }); |
| 82 | 90 | |
| 83 | 91 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ |
| ... | ... | @@ -98,6 +106,7 @@ describe("liveActivityPreferences", () => { |
| 98 | 106 | previousEnabled: false, |
| 99 | 107 | clerkToken: "clerk-token", |
| 100 | 108 | connections: [connection], |
| 109 | canConfigureEnvironment: () => true, | |
| 101 | 110 | }); |
| 102 | 111 | |
| 103 | 112 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ |
| ... | ... | @@ -118,6 +127,7 @@ describe("liveActivityPreferences", () => { |
| 118 | 127 | previousEnabled: true, |
| 119 | 128 | clerkToken: null, |
| 120 | 129 | connections: [connection], |
| 130 | canConfigureEnvironment: () => true, | |
| 121 | 131 | }); |
| 122 | 132 | |
| 123 | 133 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ |
| ... | ... | @@ -139,6 +149,7 @@ describe("liveActivityPreferences", () => { |
| 139 | 149 | previousEnabled: false, |
| 140 | 150 | clerkToken: "clerk-token", |
| 141 | 151 | connections: [connection, managedConnection], |
| 152 | canConfigureEnvironment: () => true, | |
| 142 | 153 | }); |
| 143 | 154 | |
| 144 | 155 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); |
| ... | ... | @@ -162,6 +173,7 @@ describe("liveActivityPreferences", () => { |
| 162 | 173 | previousEnabled: true, |
| 163 | 174 | clerkToken: "clerk-token", |
| 164 | 175 | connections: [connection], |
| 176 | canConfigureEnvironment: () => true, | |
| 165 | 177 | }), |
| 166 | 178 | ); |
| 167 | 179 | |
| ... | ... | @@ -184,4 +196,105 @@ describe("liveActivityPreferences", () => { |
| 184 | 196 | }); |
| 185 | 197 | }).pipe(Effect.provide(layerTest)); |
| 186 | 198 | }); |
| 199 | ||
| 200 | it.effect("only re-links environments with relay access when enabling updates", () => { | |
| 201 | const readOnlyConnection: SavedRemoteConnection = { | |
| 202 | ...connection, | |
| 203 | environmentId: "read-only" as EnvironmentId, | |
| 204 | }; | |
| 205 | ||
| 206 | return Effect.gen(function* () { | |
| 207 | yield* setLiveActivityUpdatesEnabled({ | |
| 208 | enabled: true, | |
| 209 | previousEnabled: false, | |
| 210 | clerkToken: "clerk-token", | |
| 211 | connections: [readOnlyConnection, connection], | |
| 212 | canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, | |
| 213 | }); | |
| 214 | ||
| 215 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ | |
| 216 | liveActivitiesEnabled: true, | |
| 217 | }); | |
| 218 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({ | |
| 219 | clerkToken: "clerk-token", | |
| 220 | connection, | |
| 221 | liveActivitiesEnabled: true, | |
| 222 | }); | |
| 223 | }).pipe(Effect.provide(layerTest)); | |
| 224 | }); | |
| 225 | ||
| 226 | it.effect("keeps device updates independently switchable without relay access", () => | |
| 227 | Effect.gen(function* () { | |
| 228 | for (const enabled of [false, true]) { | |
| 229 | yield* setLiveActivityUpdatesEnabled({ | |
| 230 | enabled, | |
| 231 | previousEnabled: !enabled, | |
| 232 | clerkToken: "clerk-token", | |
| 233 | connections: [connection], | |
| 234 | canConfigureEnvironment: () => false, | |
| 235 | }); | |
| 236 | } | |
| 237 | ||
| 238 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { | |
| 239 | liveActivitiesEnabled: false, | |
| 240 | }); | |
| 241 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { | |
| 242 | liveActivitiesEnabled: true, | |
| 243 | }); | |
| 244 | expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); | |
| 245 | }).pipe(Effect.provide(layerTest)), | |
| 246 | ); | |
| 247 | ||
| 248 | it.effect("checks current relay access after updating the device registration", () => { | |
| 249 | let canConfigureEnvironment = true; | |
| 250 | vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() => | |
| 251 | Effect.sync(() => { | |
| 252 | canConfigureEnvironment = false; | |
| 253 | }), | |
| 254 | ); | |
| 255 | ||
| 256 | return Effect.gen(function* () { | |
| 257 | yield* setLiveActivityUpdatesEnabled({ | |
| 258 | enabled: false, | |
| 259 | previousEnabled: true, | |
| 260 | clerkToken: "clerk-token", | |
| 261 | connections: [connection], | |
| 262 | canConfigureEnvironment: () => canConfigureEnvironment, | |
| 263 | }); | |
| 264 | ||
| 265 | expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); | |
| 266 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); | |
| 267 | }).pipe(Effect.provide(layerTest)); | |
| 268 | }); | |
| 269 | ||
| 270 | it.effect("does not retry environment changes after relay access is revoked", () => { | |
| 271 | let canConfigureEnvironment = true; | |
| 272 | vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => | |
| 273 | Effect.sync(() => { | |
| 274 | canConfigureEnvironment = false; | |
| 275 | }).pipe( | |
| 276 | Effect.andThen( | |
| 277 | Effect.fail(new CloudEnvironmentLinkError({ message: "relay access revoked" })), | |
| 278 | ), | |
| 279 | ), | |
| 280 | ); | |
| 281 | ||
| 282 | return Effect.gen(function* () { | |
| 283 | const exit = yield* Effect.exit( | |
| 284 | setLiveActivityUpdatesEnabled({ | |
| 285 | enabled: false, | |
| 286 | previousEnabled: true, | |
| 287 | clerkToken: "clerk-token", | |
| 288 | connections: [connection], | |
| 289 | canConfigureEnvironment: () => canConfigureEnvironment, | |
| 290 | }), | |
| 291 | ); | |
| 292 | ||
| 293 | expect(exit._tag).toBe("Failure"); | |
| 294 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); | |
| 295 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { | |
| 296 | liveActivitiesEnabled: true, | |
| 297 | }); | |
| 298 | }).pipe(Effect.provide(layerTest)); | |
| 299 | }); | |
| 187 | 300 | }); |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+18-11| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 4 | 5 | import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; |
| ... | ... | @@ -10,11 +11,26 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn |
| 10 | 11 | readonly previousEnabled: boolean; |
| 11 | 12 | readonly clerkToken: string | null; |
| 12 | 13 | readonly connections: ReadonlyArray<SavedRemoteConnection>; |
| 14 | readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; | |
| 13 | 15 | }) { |
| 14 | 16 | const linkedConnections = input.connections.filter( |
| 15 | 17 | (connection) => connection.bearerToken !== null, |
| 16 | 18 | ); |
| 17 | 19 | |
| 20 | const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( | |
| 21 | connection: SavedRemoteConnection, | |
| 22 | enabled: boolean, | |
| 23 | clerkToken: string, | |
| 24 | ) { | |
| 25 | if (!input.canConfigureEnvironment(connection.environmentId)) return; | |
| 26 | ||
| 27 | yield* linkEnvironmentToCloudWithPreference({ | |
| 28 | clerkToken, | |
| 29 | connection, | |
| 30 | liveActivitiesEnabled: enabled, | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 18 | 34 | const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { |
| 19 | 35 | yield* updateAgentAwarenessRegistrationPreferences({ |
| 20 | 36 | liveActivitiesEnabled: enabled, |
| ... | ... | @@ -25,12 +41,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn |
| 25 | 41 | |
| 26 | 42 | yield* Effect.forEach( |
| 27 | 43 | linkedConnections, |
| 28 | (connection) => | |
| 29 | linkEnvironmentToCloudWithPreference({ | |
| 30 | clerkToken, | |
| 31 | connection, | |
| 32 | liveActivitiesEnabled: enabled, | |
| 33 | }), | |
| 44 | (connection) => updateEnvironmentPreference(connection, enabled, clerkToken), | |
| 34 | 45 | { concurrency: "unbounded" }, |
| 35 | 46 | ); |
| 36 | 47 | }); |
| ... | ... | @@ -50,11 +61,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn |
| 50 | 61 | yield* Effect.forEach( |
| 51 | 62 | linkedConnections, |
| 52 | 63 | (connection) => |
| 53 | linkEnvironmentToCloudWithPreference({ | |
| 54 | clerkToken, | |
| 55 | connection, | |
| 56 | liveActivitiesEnabled: input.previousEnabled, | |
| 57 | }).pipe( | |
| 64 | updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe( | |
| 58 | 65 | Effect.catchCause((cause) => |
| 59 | 66 | Effect.logWarning( |
| 60 | 67 | `Could not restore Live Activity preference for environment ${connection.environmentId}.`, |
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+62-53| ... | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | 5 | import { LegendList } from "@legendapp/list/react-native"; |
| 6 | 6 | import { |
| 7 | AuthOrchestrationOperateScope, | |
| 7 | 8 | type EnvironmentId, |
| 8 | 9 | type EnvironmentMachineKind, |
| 9 | 10 | resolveEnvironmentMachineKind, |
| ... | ... | @@ -30,6 +31,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 30 | 31 | import { relativeTime } from "../../lib/time"; |
| 31 | 32 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 32 | 33 | import { useServerConfigs } from "../../state/entities"; |
| 34 | import { useEnvironmentScope } from "../../state/session"; | |
| 33 | 35 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 34 | 36 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; |
| 35 | 37 | import { SettingsScreenContent } from "../settings/components/SettingsScreen"; |
| ... | ... | @@ -191,24 +193,74 @@ function ArchivedThreadRow(props: { |
| 191 | 193 | }) { |
| 192 | 194 | const { width: windowWidth } = useWindowDimensions(); |
| 193 | 195 | const cardColor = useUniwindTheme()["--color-grouped-card"]; |
| 196 | const canOperateThread = useEnvironmentScope( | |
| 197 | props.thread.environmentId, | |
| 198 | AuthOrchestrationOperateScope, | |
| 199 | ); | |
| 194 | 200 | const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); |
| 195 | 201 | const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => |
| 196 | 202 | Boolean(part), |
| 197 | 203 | ); |
| 204 | const rowContent = ( | |
| 205 | <View | |
| 206 | className={`flex-row items-center gap-3 bg-grouped-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`} | |
| 207 | > | |
| 208 | <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> | |
| 209 | <SymbolView | |
| 210 | name="archivebox.fill" | |
| 211 | size={15} | |
| 212 | tintColorClassName="accent-icon-subtle" | |
| 213 | type="monochrome" | |
| 214 | /> | |
| 215 | </View> | |
| 216 | ||
| 217 | <View className="min-w-0 flex-1 gap-1"> | |
| 218 | <View className="flex-row items-center gap-2"> | |
| 219 | <Text | |
| 220 | className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground" | |
| 221 | numberOfLines={1} | |
| 222 | > | |
| 223 | {props.thread.title} | |
| 224 | </Text> | |
| 225 | <Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary"> | |
| 226 | {timestamp} | |
| 227 | </Text> | |
| 228 | </View> | |
| 229 | {subtitle.length > 0 ? ( | |
| 230 | <View className="flex-row items-center gap-1.5"> | |
| 231 | <SymbolView | |
| 232 | name="arrow.triangle.branch" | |
| 233 | size={10} | |
| 234 | tintColorClassName="accent-icon-subtle" | |
| 235 | type="monochrome" | |
| 236 | /> | |
| 237 | <Text | |
| 238 | className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary" | |
| 239 | numberOfLines={1} | |
| 240 | > | |
| 241 | {subtitle.join(" · ")} | |
| 242 | </Text> | |
| 243 | </View> | |
| 244 | ) : null} | |
| 245 | </View> | |
| 246 | </View> | |
| 247 | ); | |
| 248 | // Keep the group's rounded corners on both interactive and read-only rows. | |
| 249 | const containerStyle = { | |
| 250 | borderTopLeftRadius: props.isFirst ? 20 : 0, | |
| 251 | borderTopRightRadius: props.isFirst ? 20 : 0, | |
| 252 | borderBottomLeftRadius: props.isLast ? 20 : 0, | |
| 253 | borderBottomRightRadius: props.isLast ? 20 : 0, | |
| 254 | overflow: "hidden" as const, | |
| 255 | }; | |
| 256 | if (!canOperateThread) return <View style={containerStyle}>{rowContent}</View>; | |
| 257 | ||
| 198 | 258 | return ( |
| 199 | 259 | <ThreadSwipeable |
| 200 | 260 | resetKey={`${props.thread.environmentId}:${props.thread.id}`} |
| 201 | 261 | threadKey={`${props.thread.environmentId}:${props.thread.id}`} |
| 202 | 262 | backgroundColor={cardColor} |
| 203 | // Round + clip the swipeable container so the group's corners stay | |
| 204 | // rounded while rows swipe; the row itself stays square inside. | |
| 205 | containerStyle={{ | |
| 206 | borderTopLeftRadius: props.isFirst ? 20 : 0, | |
| 207 | borderTopRightRadius: props.isFirst ? 20 : 0, | |
| 208 | borderBottomLeftRadius: props.isLast ? 20 : 0, | |
| 209 | borderBottomRightRadius: props.isLast ? 20 : 0, | |
| 210 | overflow: "hidden", | |
| 211 | }} | |
| 263 | containerStyle={containerStyle} | |
| 212 | 264 | fullSwipeWidth={windowWidth - 32} |
| 213 | 265 | onDelete={props.onDelete} |
| 214 | 266 | onSwipeableClose={props.onSwipeableClose} |
| ... | ... | @@ -222,50 +274,7 @@ function ArchivedThreadRow(props: { |
| 222 | 274 | simultaneousWith={props.simultaneousSwipeGesture} |
| 223 | 275 | threadTitle={props.thread.title} |
| 224 | 276 | > |
| 225 | {() => ( | |
| 226 | <View | |
| 227 | className={`flex-row items-center gap-3 bg-grouped-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`} | |
| 228 | > | |
| 229 | <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> | |
| 230 | <SymbolView | |
| 231 | name="archivebox.fill" | |
| 232 | size={15} | |
| 233 | tintColorClassName="accent-icon-subtle" | |
| 234 | type="monochrome" | |
| 235 | /> | |
| 236 | </View> | |
| 237 | ||
| 238 | <View className="min-w-0 flex-1 gap-1"> | |
| 239 | <View className="flex-row items-center gap-2"> | |
| 240 | <Text | |
| 241 | className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground" | |
| 242 | numberOfLines={1} | |
| 243 | > | |
| 244 | {props.thread.title} | |
| 245 | </Text> | |
| 246 | <Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary"> | |
| 247 | {timestamp} | |
| 248 | </Text> | |
| 249 | </View> | |
| 250 | {subtitle.length > 0 ? ( | |
| 251 | <View className="flex-row items-center gap-1.5"> | |
| 252 | <SymbolView | |
| 253 | name="arrow.triangle.branch" | |
| 254 | size={10} | |
| 255 | tintColorClassName="accent-icon-subtle" | |
| 256 | type="monochrome" | |
| 257 | /> | |
| 258 | <Text | |
| 259 | className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary" | |
| 260 | numberOfLines={1} | |
| 261 | > | |
| 262 | {subtitle.join(" · ")} | |
| 263 | </Text> | |
| 264 | </View> | |
| 265 | ) : null} | |
| 266 | </View> | |
| 267 | </View> | |
| 268 | )} | |
| 277 | {() => rowContent} | |
| 269 | 278 | </ThreadSwipeable> |
| 270 | 279 | ); |
| 271 | 280 | } |
apps/mobile/src/features/home/useThreadListActions.test.ts created+326| ... | ... | @@ -0,0 +1,326 @@ |
| 1 | import { makeThreadShellFixture } from "../../test-fixtures"; | |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 3 | import { | |
| 4 | AuthOrchestrationOperateScope, | |
| 5 | EnvironmentId, | |
| 6 | ProjectId, | |
| 7 | ProviderInstanceId, | |
| 8 | ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Cause from "effect/Cause"; | |
| 11 | import { AsyncResult } from "effect/reactivity"; | |
| 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 13 | ||
| 14 | const state = vi.hoisted(() => ({ | |
| 15 | pendingOrder: null as object | null, | |
| 16 | dropBusy: false, | |
| 17 | scopes: new Map<string, Set<string>>(), | |
| 18 | shells: [] as EnvironmentThreadShell[], | |
| 19 | requests: [] as { | |
| 20 | action: string; | |
| 21 | environmentId: string; | |
| 22 | input: { threadId: string; orderKey?: string }; | |
| 23 | }[], | |
| 24 | dialogs: [] as { onConfirm: () => void }[], | |
| 25 | alerts: [] as { | |
| 26 | title: string; | |
| 27 | buttons?: { text: string; onPress?: () => void }[]; | |
| 28 | }[], | |
| 29 | afterRequest: undefined as (() => void) | undefined, | |
| 30 | })); | |
| 31 | ||
| 32 | vi.mock("react", () => ({ | |
| 33 | useCallback: (callback: unknown) => callback, | |
| 34 | useRef: (current: unknown) => ({ current }), | |
| 35 | })); | |
| 36 | vi.mock("react-native", () => ({ | |
| 37 | Alert: { | |
| 38 | alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => | |
| 39 | state.alerts.push({ title, buttons }), | |
| 40 | }, | |
| 41 | })); | |
| 42 | vi.mock("expo-haptics", () => ({ | |
| 43 | impactAsync: async () => {}, | |
| 44 | ImpactFeedbackStyle: { Light: "light" }, | |
| 45 | })); | |
| 46 | vi.mock("../../components/ConfirmDialogHost", () => ({ | |
| 47 | showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), | |
| 48 | })); | |
| 49 | vi.mock("../archive/useArchivedThreadSnapshots", () => ({ | |
| 50 | refreshArchivedThreadsForEnvironment: () => {}, | |
| 51 | })); | |
| 52 | vi.mock("../../state/session", () => ({ | |
| 53 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 54 | state.scopes.get(environmentId)?.has(scope) === true, | |
| 55 | })); | |
| 56 | vi.mock("../../state/server", () => ({ | |
| 57 | environmentServerConfigsAtom: "server-configs", | |
| 58 | })); | |
| 59 | vi.mock("../../state/atom-registry", () => ({ | |
| 60 | appAtomRegistry: { | |
| 61 | set: (_atom: string, value: boolean) => { | |
| 62 | state.dropBusy = value; | |
| 63 | }, | |
| 64 | get: (atom: string) => | |
| 65 | atom === "thread-drop-busy" | |
| 66 | ? state.dropBusy | |
| 67 | : atom === "thread-shells" | |
| 68 | ? state.shells | |
| 69 | : atom === "queued-thread-keys" | |
| 70 | ? new Set<string>() | |
| 71 | : new Map( | |
| 72 | [...state.scopes.keys()].map((environmentId) => [ | |
| 73 | environmentId, | |
| 74 | { | |
| 75 | environment: { | |
| 76 | capabilities: { | |
| 77 | threadSettlement: true, | |
| 78 | threadSnooze: true, | |
| 79 | threadPinning: true, | |
| 80 | threadPinReorder: true, | |
| 81 | threadTitleRegeneration: true, | |
| 82 | }, | |
| 83 | }, | |
| 84 | }, | |
| 85 | ]), | |
| 86 | ), | |
| 87 | }, | |
| 88 | })); | |
| 89 | vi.mock("../../state/use-atom-command", () => ({ | |
| 90 | useAtomCommand: (command: unknown) => command, | |
| 91 | })); | |
| 92 | // Stubbed at the direct dependency: the real outbox pulls the Expo file-system | |
| 93 | // storage into a test that only reads which threads are queued. | |
| 94 | vi.mock("../../state/use-thread-outbox", () => ({ queuedThreadKeysAtom: "queued-thread-keys" })); | |
| 95 | // The real hold lives in a module-level atom that would leak between cases. | |
| 96 | vi.mock("../../state/thread-order", () => ({ | |
| 97 | threadDropBusyAtom: "thread-drop-busy", | |
| 98 | getPendingThreadOrder: () => state.pendingOrder, | |
| 99 | beginPendingThreadOrder: () => { | |
| 100 | state.pendingOrder = {}; | |
| 101 | return { | |
| 102 | isPending: () => state.pendingOrder !== null, | |
| 103 | complete: () => { | |
| 104 | state.pendingOrder = null; | |
| 105 | }, | |
| 106 | cancel: () => { | |
| 107 | state.pendingOrder = null; | |
| 108 | }, | |
| 109 | }; | |
| 110 | }, | |
| 111 | })); | |
| 112 | vi.mock("../../state/threads", () => ({ | |
| 113 | environmentThreadShells: { threadShellsAtom: "thread-shells" }, | |
| 114 | threadEnvironment: Object.fromEntries( | |
| 115 | [ | |
| 116 | "archive", | |
| 117 | "unarchive", | |
| 118 | "delete", | |
| 119 | "settle", | |
| 120 | "unsettle", | |
| 121 | "snooze", | |
| 122 | "unsnooze", | |
| 123 | "pin", | |
| 124 | "unpin", | |
| 125 | "reorderPin", | |
| 126 | "updateMetadata", | |
| 127 | ].map((action) => [ | |
| 128 | action, | |
| 129 | async (request: { | |
| 130 | environmentId: string; | |
| 131 | input: { threadId: string; orderKey?: string }; | |
| 132 | }) => { | |
| 133 | state.requests.push({ action, ...request }); | |
| 134 | if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { | |
| 135 | return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); | |
| 136 | } | |
| 137 | state.afterRequest?.(); | |
| 138 | return AsyncResult.success(undefined); | |
| 139 | }, | |
| 140 | ]), | |
| 141 | ), | |
| 142 | })); | |
| 143 | ||
| 144 | import { useArchivedThreadListActions, useThreadListActions } from "./useThreadListActions"; | |
| 145 | ||
| 146 | const primaryEnvironmentId = EnvironmentId.make("primary"); | |
| 147 | const otherEnvironmentId = EnvironmentId.make("other"); | |
| 148 | ||
| 149 | function makeThread(input: Partial<EnvironmentThreadShell> = {}): EnvironmentThreadShell { | |
| 150 | return makeThreadShellFixture({ | |
| 151 | id: ThreadId.make("thread"), | |
| 152 | title: "Thread", | |
| 153 | environmentId: primaryEnvironmentId, | |
| 154 | projectId: ProjectId.make("project"), | |
| 155 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "model" }, | |
| 156 | ...input, | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | const mutationCases = [ | |
| 161 | ["archiveThread", "archive"], | |
| 162 | ["settleThread", "settle"], | |
| 163 | ["unsettleThread", "unsettle"], | |
| 164 | ["snoozeThread", "snooze"], | |
| 165 | ["unsnoozeThread", "unsnooze"], | |
| 166 | ["pinThread", "pin"], | |
| 167 | ["unpinThread", "unpin"], | |
| 168 | ["regenerateThreadTitle", "updateMetadata"], | |
| 169 | ] as const; | |
| 170 | ||
| 171 | beforeEach(() => { | |
| 172 | state.pendingOrder = null; | |
| 173 | state.dropBusy = false; | |
| 174 | state.scopes = new Map([ | |
| 175 | [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])], | |
| 176 | [otherEnvironmentId, new Set<string>()], | |
| 177 | ]); | |
| 178 | state.requests = []; | |
| 179 | state.shells = []; | |
| 180 | state.dialogs = []; | |
| 181 | state.alerts = []; | |
| 182 | state.afterRequest = undefined; | |
| 183 | }); | |
| 184 | ||
| 185 | afterEach(() => vi.unstubAllEnvs()); | |
| 186 | ||
| 187 | describe("thread list operation permissions", () => { | |
| 188 | it.each(mutationCases)( | |
| 189 | "%s rejects a retained callback after its environment loses permission", | |
| 190 | async (handler) => { | |
| 191 | const actions = useThreadListActions(); | |
| 192 | state.scopes.get(primaryEnvironmentId)!.clear(); | |
| 193 | ||
| 194 | await actions[handler](makeThread(), "2099-01-01T00:00:00.000Z"); | |
| 195 | ||
| 196 | expect(state.requests).toEqual([]); | |
| 197 | }, | |
| 198 | ); | |
| 199 | ||
| 200 | it.each(mutationCases)("%s requires the target environment's permission", async (handler) => { | |
| 201 | await useThreadListActions()[handler]( | |
| 202 | makeThread({ environmentId: otherEnvironmentId }), | |
| 203 | "2099-01-01T00:00:00.000Z", | |
| 204 | ); | |
| 205 | ||
| 206 | expect(state.requests).toEqual([]); | |
| 207 | }); | |
| 208 | ||
| 209 | it.each(mutationCases)( | |
| 210 | "%s works when the target environment gains only task permission", | |
| 211 | async (handler, action) => { | |
| 212 | state.scopes.get(primaryEnvironmentId)!.clear(); | |
| 213 | const actions = useThreadListActions(); | |
| 214 | state.scopes.get(otherEnvironmentId)!.add(AuthOrchestrationOperateScope); | |
| 215 | ||
| 216 | await actions[handler]( | |
| 217 | makeThread({ environmentId: otherEnvironmentId }), | |
| 218 | "2099-01-01T00:00:00.000Z", | |
| 219 | ); | |
| 220 | ||
| 221 | expect(state.requests).toEqual([ | |
| 222 | expect.objectContaining({ action, environmentId: otherEnvironmentId }), | |
| 223 | ]); | |
| 224 | }, | |
| 225 | ); | |
| 226 | ||
| 227 | it.each(["ios", "android"])("does not show a forbidden %s delete confirmation", (os) => { | |
| 228 | vi.stubEnv("EXPO_OS", os); | |
| 229 | useThreadListActions().confirmDeleteThread(makeThread({ environmentId: otherEnvironmentId })); | |
| 230 | ||
| 231 | expect(state.dialogs).toEqual([]); | |
| 232 | expect(state.alerts.some((alert) => alert.title === "Delete thread?")).toBe(false); | |
| 233 | expect(state.requests).toEqual([]); | |
| 234 | }); | |
| 235 | ||
| 236 | it.each(["ios", "android"])( | |
| 237 | "rechecks permission after a retained %s delete confirmation", | |
| 238 | async (os) => { | |
| 239 | vi.stubEnv("EXPO_OS", os); | |
| 240 | useThreadListActions().confirmDeleteThread(makeThread()); | |
| 241 | const confirm = | |
| 242 | os === "ios" | |
| 243 | ? state.alerts[0]?.buttons?.find((button) => button.text === "Delete")?.onPress | |
| 244 | : state.dialogs[0]?.onConfirm; | |
| 245 | expect(confirm).toBeTypeOf("function"); | |
| 246 | state.scopes.get(primaryEnvironmentId)!.clear(); | |
| 247 | ||
| 248 | await confirm!(); | |
| 249 | ||
| 250 | expect(state.requests).toEqual([]); | |
| 251 | }, | |
| 252 | ); | |
| 253 | ||
| 254 | it("unarchives with only task permission and blocks a later revoked callback", async () => { | |
| 255 | const actions = useArchivedThreadListActions(() => {}); | |
| 256 | const thread = makeThread({ archivedAt: "2026-09-02T00:00:00.000Z" }); | |
| 257 | await actions.unarchiveThread(thread); | |
| 258 | expect(state.requests).toEqual([expect.objectContaining({ action: "unarchive" })]); | |
| 259 | state.requests = []; | |
| 260 | state.scopes.get(primaryEnvironmentId)!.clear(); | |
| 261 | ||
| 262 | await actions.unarchiveThread(thread); | |
| 263 | ||
| 264 | expect(state.requests).toEqual([]); | |
| 265 | }); | |
| 266 | ||
| 267 | it("keeps delete independent of terminal and source-control permissions", async () => { | |
| 268 | vi.stubEnv("EXPO_OS", "android"); | |
| 269 | useArchivedThreadListActions(() => {}).confirmDeleteThread(makeThread()); | |
| 270 | await state.dialogs[0]!.onConfirm(); | |
| 271 | ||
| 272 | expect(state.requests).toEqual([expect.objectContaining({ action: "delete" })]); | |
| 273 | }); | |
| 274 | }); | |
| 275 | ||
| 276 | describe("pinned thread operation permissions", () => { | |
| 277 | it("checks every materialization target before writing any keys", async () => { | |
| 278 | const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" }); | |
| 279 | state.shells = [ | |
| 280 | moved, | |
| 281 | makeThread({ | |
| 282 | id: ThreadId.make("other-thread"), | |
| 283 | environmentId: otherEnvironmentId, | |
| 284 | pinnedAt: "2026-09-02T00:00:00.000Z", | |
| 285 | createdAt: "2026-09-02T00:00:00.000Z", | |
| 286 | }), | |
| 287 | ]; | |
| 288 | ||
| 289 | expect(await useThreadListActions().moveThread(moved, "up")).toBe(false); | |
| 290 | expect(state.requests).toEqual([]); | |
| 291 | }); | |
| 292 | ||
| 293 | it("moves a keyed thread past a read-only neighbor without writing that neighbor", async () => { | |
| 294 | const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", pinOrderKey: "m" }); | |
| 295 | state.shells = [ | |
| 296 | moved, | |
| 297 | makeThread({ | |
| 298 | id: ThreadId.make("other-thread"), | |
| 299 | environmentId: otherEnvironmentId, | |
| 300 | pinnedAt: "2026-09-02T00:00:00.000Z", | |
| 301 | pinOrderKey: "g", | |
| 302 | }), | |
| 303 | ]; | |
| 304 | ||
| 305 | expect(await useThreadListActions().moveThread(moved, "up")).toBe(true); | |
| 306 | expect(state.requests).toEqual([ | |
| 307 | expect.objectContaining({ action: "reorderPin", environmentId: primaryEnvironmentId }), | |
| 308 | ]); | |
| 309 | }); | |
| 310 | ||
| 311 | it("rechecks permission between materialization writes", async () => { | |
| 312 | const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" }); | |
| 313 | state.shells = [ | |
| 314 | moved, | |
| 315 | makeThread({ | |
| 316 | id: ThreadId.make("other-thread"), | |
| 317 | pinnedAt: "2026-09-02T00:00:00.000Z", | |
| 318 | createdAt: "2026-09-02T00:00:00.000Z", | |
| 319 | }), | |
| 320 | ]; | |
| 321 | state.afterRequest = () => state.scopes.get(primaryEnvironmentId)!.clear(); | |
| 322 | ||
| 323 | expect(await useThreadListActions().moveThread(moved, "up")).toBe(false); | |
| 324 | expect(state.requests).toHaveLength(1); | |
| 325 | }); | |
| 326 | }); |
apps/mobile/src/features/home/useThreadListActions.ts+22| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 2 | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | 3 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 4 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 4 | 5 | import * as Cause from "effect/Cause"; |
| 5 | 6 | import * as Haptics from "expo-haptics"; |
| 6 | 7 | import { useCallback, useRef } from "react"; |
| ... | ... | @@ -13,6 +14,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre |
| 13 | 14 | import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; |
| 14 | 15 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 15 | 16 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 17 | import { readEnvironmentScope } from "../../state/session"; | |
| 16 | 18 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 17 | 19 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; |
| 18 | 20 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -108,6 +110,12 @@ function actionFailureTitle(action: ThreadListAction): string { |
| 108 | 110 | return "Could not delete thread"; |
| 109 | 111 | } |
| 110 | 112 | |
| 113 | function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean { | |
| 114 | if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; | |
| 115 | Alert.alert(title, "This connection cannot change threads."); | |
| 116 | return false; | |
| 117 | } | |
| 118 | ||
| 111 | 119 | /** Resolves to true iff the action was dispatched and succeeded. */ |
| 112 | 120 | function useThreadActionExecutor( |
| 113 | 121 | onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, |
| ... | ... | @@ -121,6 +129,7 @@ function useThreadActionExecutor( |
| 121 | 129 | |
| 122 | 130 | const executeAction = useCallback( |
| 123 | 131 | async (action: ThreadListAction, thread: EnvironmentThreadShell) => { |
| 132 | if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; | |
| 124 | 133 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 125 | 134 | if (inFlightThreadKeys.current.has(key)) { |
| 126 | 135 | return false; |
| ... | ... | @@ -205,6 +214,7 @@ function useConfirmDeleteThread( |
| 205 | 214 | ) { |
| 206 | 215 | return useCallback( |
| 207 | 216 | (thread: EnvironmentThreadShell) => { |
| 217 | if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return; | |
| 208 | 218 | const title = "Delete thread?"; |
| 209 | 219 | const message = `“${thread.title}” will be permanently deleted, including its terminal history.`; |
| 210 | 220 | if (process.env.EXPO_OS === "ios") { |
| ... | ... | @@ -281,6 +291,7 @@ export function useThreadListActions(): { |
| 281 | 291 | ); |
| 282 | 292 | const snoozeThread = useCallback( |
| 283 | 293 | async (thread: EnvironmentThreadShell, snoozedUntil: string) => { |
| 294 | if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; | |
| 284 | 295 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 285 | 296 | if (snoozeInFlightThreadKeys.current.has(key)) { |
| 286 | 297 | return false; |
| ... | ... | @@ -336,6 +347,7 @@ export function useThreadListActions(): { |
| 336 | 347 | ); |
| 337 | 348 | const unsnoozeThread = useCallback( |
| 338 | 349 | async (thread: EnvironmentThreadShell) => { |
| 350 | if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; | |
| 339 | 351 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 340 | 352 | if (snoozeInFlightThreadKeys.current.has(key)) { |
| 341 | 353 | return false; |
| ... | ... | @@ -383,6 +395,7 @@ export function useThreadListActions(): { |
| 383 | 395 | ); |
| 384 | 396 | const pinThread = useCallback( |
| 385 | 397 | async (thread: EnvironmentThreadShell) => { |
| 398 | if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; | |
| 386 | 399 | if (!environmentSupportsPinning(thread.environmentId)) { |
| 387 | 400 | Alert.alert( |
| 388 | 401 | "Could not pin thread", |
| ... | ... | @@ -423,6 +436,7 @@ export function useThreadListActions(): { |
| 423 | 436 | ); |
| 424 | 437 | const unpinThread = useCallback( |
| 425 | 438 | async (thread: EnvironmentThreadShell) => { |
| 439 | if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; | |
| 426 | 440 | if (!environmentSupportsPinning(thread.environmentId)) { |
| 427 | 441 | Alert.alert( |
| 428 | 442 | "Could not unpin thread", |
| ... | ... | @@ -479,6 +493,7 @@ export function useThreadListActions(): { |
| 479 | 493 | ); |
| 480 | 494 | const regenerateThreadTitle = useCallback( |
| 481 | 495 | async (thread: EnvironmentThreadShell) => { |
| 496 | if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false; | |
| 482 | 497 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 483 | 498 | if ( |
| 484 | 499 | thread.titleRegeneration != null || |
| ... | ... | @@ -572,6 +587,7 @@ export function useThreadListActions(): { |
| 572 | 587 | }); |
| 573 | 588 | const moveThread = useCallback( |
| 574 | 589 | async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { |
| 590 | if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; | |
| 575 | 591 | if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; |
| 576 | 592 | const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); |
| 577 | 593 | const current = shells.find( |
| ... | ... | @@ -648,6 +664,11 @@ export function useThreadListActions(): { |
| 648 | 664 | const shellByKey = new Map( |
| 649 | 665 | shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), |
| 650 | 666 | ); |
| 667 | for (const assignment of assignments) { | |
| 668 | const target = shellByKey.get(assignment.id); | |
| 669 | if (target && !checkThreadOperationPermission(target, "Could not move thread")) | |
| 670 | return false; | |
| 671 | } | |
| 651 | 672 | selectionHaptic(); |
| 652 | 673 | appAtomRegistry.set(threadDropBusyAtom, true); |
| 653 | 674 | const pending = crossSection |
| ... | ... | @@ -694,6 +715,7 @@ export function useThreadListActions(): { |
| 694 | 715 | if (pending !== null && !pending.isPending()) return false; |
| 695 | 716 | const target = shellByKey.get(assignment.id); |
| 696 | 717 | if (target === undefined) continue; |
| 718 | if (!checkThreadOperationPermission(target, "Could not move thread")) return false; | |
| 697 | 719 | const result = await reorder({ |
| 698 | 720 | environmentId: target.environmentId, |
| 699 | 721 | input: { threadId: target.id, orderKey: assignment.orderKey }, |
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx+33-7| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | AuthProvidersManageScope, | |
| 5 | AuthOrchestrationReadScope, | |
| 6 | type EnvironmentId, | |
| 7 | type ServerProvider, | |
| 8 | } from "@t3tools/contracts"; | |
| 4 | 9 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | 10 | import { AsyncResult } from "effect/reactivity"; |
| 6 | 11 | import { useEffect, useRef, useState } from "react"; |
| ... | ... | @@ -10,8 +15,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 15 | import { AppText as Text } from "../../components/AppText"; |
| 11 | 16 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 12 | 17 | import { ScreenScrollView } from "../../components/ScreenScrollView"; |
| 18 | import { appAtomRegistry } from "../../state/atom-registry"; | |
| 13 | 19 | import { serverEnvironment } from "../../state/server"; |
| 14 | import { environmentSession } from "../../state/session"; | |
| 20 | import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session"; | |
| 15 | 21 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | 22 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 17 | 23 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; |
| ... | ... | @@ -75,7 +81,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm |
| 75 | 81 | (provider) => |
| 76 | 82 | provider.updateState?.status === "running" || provider.updateState?.status === "queued", |
| 77 | 83 | ) ?? false; |
| 78 | const disabled = !allowed || pending !== null || running || providerBusy; | |
| 84 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 85 | const canRead = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); | |
| 86 | const busy = !connected || pending !== null || running || providerBusy; | |
| 87 | const disabled = !allowed || busy; | |
| 79 | 88 | const version = config?.environment.serverVersion; |
| 80 | 89 | const checkedRelease = release?.fromVersion === version ? release : null; |
| 81 | 90 | const capabilities = config?.environment.capabilities; |
| ... | ... | @@ -111,6 +120,16 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm |
| 111 | 120 | text: "Update", |
| 112 | 121 | onPress: () => |
| 113 | 122 | void run("server", async () => { |
| 123 | if ( | |
| 124 | AsyncResult.isFailure( | |
| 125 | appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), | |
| 126 | ) || | |
| 127 | !canMaintainEnvironment( | |
| 128 | appAtomRegistry.get(environmentSession.sessionStateValueAtom(environmentId)), | |
| 129 | connected, | |
| 130 | ) | |
| 131 | ) | |
| 132 | return; | |
| 114 | 133 | const result = await updateServer({ |
| 115 | 134 | environmentId, |
| 116 | 135 | input: { |
| ... | ... | @@ -131,7 +150,12 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm |
| 131 | 150 | } |
| 132 | 151 | |
| 133 | 152 | function requestProviderUpdate(provider: ServerProvider) { |
| 134 | if (disabled || !canUpdateEnvironmentProvider(provider)) return; | |
| 153 | if ( | |
| 154 | busy || | |
| 155 | !readEnvironmentScope(environmentId, AuthProvidersManageScope) || | |
| 156 | !canUpdateEnvironmentProvider(provider) | |
| 157 | ) | |
| 158 | return; | |
| 135 | 159 | void run(provider.instanceId, async () => { |
| 136 | 160 | const result = await updateProvider({ |
| 137 | 161 | environmentId, |
| ... | ... | @@ -274,11 +298,13 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm |
| 274 | 298 | <SettingsActionRow |
| 275 | 299 | icon="arrow.clockwise" |
| 276 | 300 | label="Refresh providers" |
| 277 | disabled={disabled} | |
| 301 | disabled={busy || !canRead} | |
| 278 | 302 | loading={pending === "refresh"} |
| 279 | 303 | onPress={() => { |
| 280 | if (disabled) return; | |
| 304 | if (busy || !canRead) return; | |
| 281 | 305 | void run("refresh", async () => { |
| 306 | if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope)) | |
| 307 | return; | |
| 282 | 308 | const result = await refreshProviders({ environmentId, input: {} }); |
| 283 | 309 | if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); |
| 284 | 310 | setNotice("Provider status refreshed."); |
| ... | ... | @@ -338,7 +364,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm |
| 338 | 364 | <SettingsActionRow |
| 339 | 365 | icon="arrow.up.circle" |
| 340 | 366 | label={`Update ${provider.displayName ?? provider.driver}`} |
| 341 | disabled={disabled} | |
| 367 | disabled={busy || !canManageProviders} | |
| 342 | 368 | loading={pending === provider.instanceId} |
| 343 | 369 | onPress={() => requestProviderUpdate(provider)} |
| 344 | 370 | /> |
apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx+7-1| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthRelayWriteScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | 4 | import { useAuth } from "@clerk/expo"; |
| 3 | 5 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| ... | ... | @@ -271,6 +273,8 @@ function ConfiguredSettingsNotificationsRouteScreen() { |
| 271 | 273 | const updateResult = await settleAsyncResult(() => |
| 272 | 274 | runtime.runPromiseExit( |
| 273 | 275 | setLiveActivityUpdatesEnabled({ |
| 276 | canConfigureEnvironment: (environmentId) => | |
| 277 | readEnvironmentScope(environmentId, AuthRelayWriteScope), | |
| 274 | 278 | enabled: true, |
| 275 | 279 | previousEnabled: liveActivitiesPreferenceEnabled, |
| 276 | 280 | clerkToken: tokenResult.value, |
| ... | ... | @@ -302,7 +306,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { |
| 302 | 306 | Alert.alert( |
| 303 | 307 | Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", |
| 304 | 308 | environmentCount > 0 |
| 305 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` | |
| 309 | ? "Agent activity updates are enabled for environments this connection can configure." | |
| 306 | 310 | : "Agent activity updates are enabled. Add an environment to start receiving updates.", |
| 307 | 311 | ); |
| 308 | 312 | } else { |
| ... | ... | @@ -370,6 +374,8 @@ function ConfiguredSettingsNotificationsRouteScreen() { |
| 370 | 374 | const updateResult = await settleAsyncResult(() => |
| 371 | 375 | runtime.runPromiseExit( |
| 372 | 376 | setLiveActivityUpdatesEnabled({ |
| 377 | canConfigureEnvironment: (environmentId) => | |
| 378 | readEnvironmentScope(environmentId, AuthRelayWriteScope), | |
| 373 | 379 | enabled: false, |
| 374 | 380 | previousEnabled: liveActivitiesPreferenceEnabled, |
| 375 | 381 | clerkToken: token, |
apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts+60-1| ... | ... | @@ -1,6 +1,65 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | |
| 4 | import { | |
| 5 | resolveAgentAwarenessPlatformPresentation, | |
| 6 | resolveAutoSettleReferenceEnvironmentId, | |
| 7 | } from "./SettingsRouteScreen.logic"; | |
| 8 | ||
| 9 | describe("resolveAutoSettleReferenceEnvironmentId", () => { | |
| 10 | const firstId = EnvironmentId.make("first"); | |
| 11 | const secondId = EnvironmentId.make("second"); | |
| 12 | ||
| 13 | it("waits for an earlier grant before exposing a later writable reference", () => { | |
| 14 | const second = { environmentId: secondId, canWriteSettings: true }; | |
| 15 | expect( | |
| 16 | resolveAutoSettleReferenceEnvironmentId([ | |
| 17 | { environmentId: firstId, canWriteSettings: null }, | |
| 18 | second, | |
| 19 | ]), | |
| 20 | ).toBeNull(); | |
| 21 | expect( | |
| 22 | resolveAutoSettleReferenceEnvironmentId([ | |
| 23 | { environmentId: firstId, canWriteSettings: false }, | |
| 24 | second, | |
| 25 | ]), | |
| 26 | ).toBe(secondId); | |
| 27 | expect( | |
| 28 | resolveAutoSettleReferenceEnvironmentId([ | |
| 29 | { environmentId: firstId, canWriteSettings: true }, | |
| 30 | second, | |
| 31 | ]), | |
| 32 | ).toBe(firstId); | |
| 33 | }); | |
| 34 | ||
| 35 | it("does not wait for later grants after finding the first writable reference", () => { | |
| 36 | expect( | |
| 37 | resolveAutoSettleReferenceEnvironmentId([ | |
| 38 | { environmentId: firstId, canWriteSettings: true }, | |
| 39 | { environmentId: secondId, canWriteSettings: null }, | |
| 40 | ]), | |
| 41 | ).toBe(firstId); | |
| 42 | }); | |
| 43 | ||
| 44 | it("waits before showing a read-only fallback until all grants are resolved", () => { | |
| 45 | expect( | |
| 46 | resolveAutoSettleReferenceEnvironmentId([ | |
| 47 | { environmentId: firstId, canWriteSettings: false }, | |
| 48 | { environmentId: secondId, canWriteSettings: null }, | |
| 49 | ]), | |
| 50 | ).toBeNull(); | |
| 51 | expect( | |
| 52 | resolveAutoSettleReferenceEnvironmentId([ | |
| 53 | { environmentId: firstId, canWriteSettings: false }, | |
| 54 | { environmentId: secondId, canWriteSettings: false }, | |
| 55 | ]), | |
| 56 | ).toBe(firstId); | |
| 57 | }); | |
| 58 | ||
| 59 | it("has no reference when no environment supports synchronization", () => { | |
| 60 | expect(resolveAutoSettleReferenceEnvironmentId([])).toBeNull(); | |
| 61 | }); | |
| 62 | }); | |
| 4 | 63 | |
| 5 | 64 | describe("resolveAgentAwarenessPlatformPresentation", () => { |
| 6 | 65 | it("supports agent awareness settings on Android", () => { |
apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts+16| ... | ... | @@ -1,3 +1,19 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | /** Wait for earlier grants before choosing the settings that edits and synchronization use. */ | |
| 4 | export function resolveAutoSettleReferenceEnvironmentId( | |
| 5 | environments: ReadonlyArray<{ | |
| 6 | readonly environmentId: EnvironmentId; | |
| 7 | readonly canWriteSettings: boolean | null; | |
| 8 | }>, | |
| 9 | ): EnvironmentId | null { | |
| 10 | for (const environment of environments) { | |
| 11 | if (environment.canWriteSettings === null) return null; | |
| 12 | if (environment.canWriteSettings) return environment.environmentId; | |
| 13 | } | |
| 14 | return environments[0]?.environmentId ?? null; | |
| 15 | } | |
| 16 | ||
| 1 | 17 | export function resolveAgentAwarenessPlatformPresentation(platform: string): { |
| 2 | 18 | readonly supported: boolean; |
| 3 | 19 | readonly subtitle: string | undefined; |
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+28-6| ... | ... | @@ -1,3 +1,6 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 3 | import { readEnvironmentScope } from "../../state/session"; | |
| 1 | 4 | import type { |
| 2 | 5 | EnvironmentId, |
| 3 | 6 | ProjectId, |
| ... | ... | @@ -580,6 +583,9 @@ function TaskForm({ |
| 580 | 583 | const projects = useProjects().filter((project) => project.environmentId === environmentId); |
| 581 | 584 | const config = useEnvironmentServerConfig(environmentId); |
| 582 | 585 | const modelOptions = useMemo(() => buildModelOptions(config, null), [config]); |
| 586 | const canOperate = useAtomValue( | |
| 587 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), | |
| 588 | ); | |
| 583 | 589 | const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, { |
| 584 | 590 | label: "scheduled task upsert", |
| 585 | 591 | reportFailure: false, |
| ... | ... | @@ -602,6 +608,7 @@ function TaskForm({ |
| 602 | 608 | |
| 603 | 609 | const save = async () => { |
| 604 | 610 | if ( |
| 611 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 605 | 612 | submissionPending.current || |
| 606 | 613 | saving || |
| 607 | 614 | dictationPending || |
| ... | ... | @@ -937,7 +944,7 @@ function TaskForm({ |
| 937 | 944 | : draft.task |
| 938 | 945 | } |
| 939 | 946 | signatureConfigured={draft.schedule.signature !== null} |
| 940 | disabled={saving || environmentUnavailable} | |
| 947 | disabled={!canOperate || saving || environmentUnavailable} | |
| 941 | 948 | /> |
| 942 | 949 | <FormField |
| 943 | 950 | label="Skip requests older than (minutes)" |
| ... | ... | @@ -994,7 +1001,9 @@ function TaskForm({ |
| 994 | 1001 | accessibilityState={{ |
| 995 | 1002 | disabled: saving || dictationPending || taskMissing || environmentUnavailable, |
| 996 | 1003 | }} |
| 997 | disabled={saving || dictationPending || taskMissing || environmentUnavailable} | |
| 1004 | disabled={ | |
| 1005 | !canOperate || saving || dictationPending || taskMissing || environmentUnavailable | |
| 1006 | } | |
| 998 | 1007 | onPress={() => void save()} |
| 999 | 1008 | className="min-h-12 items-center justify-center rounded-[14px] bg-primary px-4 disabled:opacity-50" |
| 1000 | 1009 | > |
| ... | ... | @@ -1118,6 +1127,9 @@ function EnvironmentTasks({ |
| 1118 | 1127 | const visibleTasks = tasks.data?.tasks.filter( |
| 1119 | 1128 | (task) => projectIds === null || projectIds.includes(task.projectId), |
| 1120 | 1129 | ); |
| 1130 | const canOperate = useAtomValue( | |
| 1131 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), | |
| 1132 | ); | |
| 1121 | 1133 | const setEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { |
| 1122 | 1134 | label: "scheduled task enabled", |
| 1123 | 1135 | reportFailure: false, |
| ... | ... | @@ -1137,6 +1149,7 @@ function EnvironmentTasks({ |
| 1137 | 1149 | }; |
| 1138 | 1150 | |
| 1139 | 1151 | const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => { |
| 1152 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 1140 | 1153 | const result = |
| 1141 | 1154 | action === "run" |
| 1142 | 1155 | ? await runNow({ environmentId, input: { id: task.id } }) |
| ... | ... | @@ -1205,10 +1218,19 @@ function EnvironmentTasks({ |
| 1205 | 1218 | <ControlPillMenu |
| 1206 | 1219 | actions={[ |
| 1207 | 1220 | { id: "edit", title: "Edit" }, |
| 1208 | { id: "toggle", title: task.enabled ? "Pause" : "Resume" }, | |
| 1209 | // A webhook task has no request to run without. | |
| 1210 | ...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]), | |
| 1211 | { id: "delete", title: "Delete", attributes: { destructive: true } }, | |
| 1221 | { | |
| 1222 | id: "toggle", | |
| 1223 | title: task.enabled ? "Pause" : "Resume", | |
| 1224 | attributes: { disabled: !canOperate }, | |
| 1225 | }, | |
| 1226 | ...(task.schedule.type === "webhook" | |
| 1227 | ? [] | |
| 1228 | : [{ id: "run", title: "Run now", attributes: { disabled: !canOperate } }]), | |
| 1229 | { | |
| 1230 | id: "delete", | |
| 1231 | title: "Delete", | |
| 1232 | attributes: { destructive: true, disabled: !canOperate }, | |
| 1233 | }, | |
| 1212 | 1234 | ]} |
| 1213 | 1235 | onPressAction={({ nativeEvent }) => { |
| 1214 | 1236 | const action = nativeEvent.event; |
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+26-3| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | 2 | import { SettingsRow } from "./components/SettingsRow"; |
| 3 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 4 | import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; | |
| 3 | 5 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 4 | 6 | import { AppText as Text } from "../../components/AppText"; |
| 5 | 7 | import { |
| ... | ... | @@ -141,6 +143,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 141 | 143 | const navigation = useNavigation(); |
| 142 | 144 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); |
| 143 | 145 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); |
| 146 | const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); | |
| 144 | 147 | const projectSelected = selectedProjectKey !== null; |
| 145 | 148 | const targets = resolveMobileSettingsTargets( |
| 146 | 149 | selectedTargets, |
| ... | ... | @@ -151,6 +154,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 151 | 154 | const [pendingTargets, setPendingTargets] = useState< |
| 152 | 155 | readonly ScopedMobileSettingsTarget[] | null |
| 153 | 156 | >(null); |
| 157 | const canWriteSettings = | |
| 158 | targets.length > 0 && | |
| 159 | targets.every((target) => writableEnvironments.has(target.environment.environmentId)); | |
| 154 | 160 | const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets; |
| 155 | 161 | const hasConnectedSelection = targets.length > 0; |
| 156 | 162 | const reference = displayTargets[0] ?? null; |
| ... | ... | @@ -165,7 +171,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 165 | 171 | reportFailure: true, |
| 166 | 172 | }); |
| 167 | 173 | const write = (patch: ServerSettingsPatch) => { |
| 168 | if (writeInFlight.current || !hasConnectedSelection) return; | |
| 174 | if ( | |
| 175 | writeInFlight.current || | |
| 176 | !hasConnectedSelection || | |
| 177 | !targets.every((target) => | |
| 178 | readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), | |
| 179 | ) | |
| 180 | ) | |
| 181 | return; | |
| 169 | 182 | const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch); |
| 170 | 183 | if (writes.length === 0) return; |
| 171 | 184 | writeInFlight.current = true; |
| ... | ... | @@ -182,7 +195,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 182 | 195 | }); |
| 183 | 196 | }; |
| 184 | 197 | const clearProjectOverrides = () => { |
| 185 | if (writeInFlight.current) return; | |
| 198 | if ( | |
| 199 | writeInFlight.current || | |
| 200 | !targets.every((target) => | |
| 201 | readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), | |
| 202 | ) | |
| 203 | ) | |
| 204 | return; | |
| 186 | 205 | const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]); |
| 187 | 206 | if (writes.length === 0) return; |
| 188 | 207 | writeInFlight.current = true; |
| ... | ... | @@ -203,7 +222,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 203 | 222 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, |
| 204 | 223 | ); |
| 205 | 224 | const disabled = |
| 206 | pendingWrites > 0 || !hasConnectedSelection || (projectSelected && !supportsProjectOverrides); | |
| 225 | !canWriteSettings || | |
| 226 | pendingWrites > 0 || | |
| 227 | !hasConnectedSelection || | |
| 228 | (projectSelected && !supportsProjectOverrides); | |
| 207 | 229 | const supportsContinuation = targets.every( |
| 208 | 230 | (target) => |
| 209 | 231 | target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true, |
| ... | ... | @@ -245,6 +267,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 245 | 267 | )} |
| 246 | 268 | supportsOverrides={supportsProjectOverrides} |
| 247 | 269 | pending={pendingWrites > 0} |
| 270 | disabled={!canWriteSettings} | |
| 248 | 271 | onClear={clearProjectOverrides} |
| 249 | 272 | /> |
| 250 | 273 | ) : null} |
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx+23-3| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; | |
| 1 | 3 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; |
| 2 | 4 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 3 | 5 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| ... | ... | @@ -61,6 +63,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD |
| 61 | 63 | function AutoSettleSettingsRows() { |
| 62 | 64 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); |
| 63 | 65 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); |
| 66 | const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); | |
| 64 | 67 | const projectSelected = selectedProjectKey !== null; |
| 65 | 68 | const [pendingWrites, setPendingWrites] = useState(0); |
| 66 | 69 | const writeInFlight = useRef(false); |
| ... | ... | @@ -77,6 +80,9 @@ function AutoSettleSettingsRows() { |
| 77 | 80 | syncEnvironments, |
| 78 | 81 | projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, |
| 79 | 82 | ); |
| 83 | const canWriteSettings = | |
| 84 | syncTargets.length > 0 && | |
| 85 | syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId)); | |
| 80 | 86 | const displayTargets = |
| 81 | 87 | pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets; |
| 82 | 88 | const reference = displayTargets[0] ?? null; |
| ... | ... | @@ -92,7 +98,13 @@ function AutoSettleSettingsRows() { |
| 92 | 98 | snoozeLimitedThreads?: boolean; |
| 93 | 99 | }, |
| 94 | 100 | ) => { |
| 95 | if (writeInFlight.current) return; | |
| 101 | if ( | |
| 102 | writeInFlight.current || | |
| 103 | !syncTargets.every((target) => | |
| 104 | readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), | |
| 105 | ) | |
| 106 | ) | |
| 107 | return; | |
| 96 | 108 | const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); |
| 97 | 109 | if (writes.length === 0) return; |
| 98 | 110 | writeInFlight.current = true; |
| ... | ... | @@ -127,7 +139,8 @@ function AutoSettleSettingsRows() { |
| 127 | 139 | (target) => |
| 128 | 140 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, |
| 129 | 141 | ); |
| 130 | const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); | |
| 142 | const disabled = | |
| 143 | !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); | |
| 131 | 144 | const hasProjectOverrides = |
| 132 | 145 | projectSelected && |
| 133 | 146 | syncTargets.some( |
| ... | ... | @@ -136,7 +149,13 @@ function AutoSettleSettingsRows() { |
| 136 | 149 | target.sources.sidebarAutoSettleAfterDays === "project", |
| 137 | 150 | ); |
| 138 | 151 | const clearProjectOverrides = () => { |
| 139 | if (writeInFlight.current) return; | |
| 152 | if ( | |
| 153 | writeInFlight.current || | |
| 154 | !syncTargets.every((target) => | |
| 155 | readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), | |
| 156 | ) | |
| 157 | ) | |
| 158 | return; | |
| 140 | 159 | const writes = planMobileScopedSettingsClear(syncTargets, [ |
| 141 | 160 | "sidebarAutoSettleOnMerge", |
| 142 | 161 | "sidebarAutoSettleAfterDays", |
| ... | ... | @@ -166,6 +185,7 @@ function AutoSettleSettingsRows() { |
| 166 | 185 | hasOverrides={hasProjectOverrides} |
| 167 | 186 | supportsOverrides={supportsProjectOverrides} |
| 168 | 187 | pending={pendingWrites > 0} |
| 188 | disabled={!canWriteSettings} | |
| 169 | 189 | onClear={clearProjectOverrides} |
| 170 | 190 | /> |
| 171 | 191 | ) : null} |
apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx+2| ... | ... | @@ -8,6 +8,7 @@ export function SettingsProjectOverridesSection(props: { |
| 8 | 8 | readonly hasOverrides: boolean; |
| 9 | 9 | readonly supportsOverrides: boolean; |
| 10 | 10 | readonly pending: boolean; |
| 11 | readonly disabled?: boolean; | |
| 11 | 12 | readonly onClear: () => void; |
| 12 | 13 | }) { |
| 13 | 14 | return ( |
| ... | ... | @@ -20,6 +21,7 @@ export function SettingsProjectOverridesSection(props: { |
| 20 | 21 | <Pressable |
| 21 | 22 | accessibilityRole="button" |
| 22 | 23 | accessibilityLabel="Use environment defaults" |
| 24 | disabled={props.disabled} | |
| 23 | 25 | onPress={props.onClear} |
| 24 | 26 | className="px-2 py-2 active:opacity-70" |
| 25 | 27 | > |
apps/mobile/src/features/settings/environment-maintenance.test.ts+23| ... | ... | @@ -53,6 +53,29 @@ describe("environment maintenance access", () => { |
| 53 | 53 | expect(canMaintainEnvironment(null, true)).toBe(false); |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | it.each([ | |
| 57 | { scopes: ["environment:maintain"], expected: true }, | |
| 58 | { scopes: ["orchestration:operate"], expected: false }, | |
| 59 | { scopes: ["providers:manage"], expected: false }, | |
| 60 | ] as const)("uses the advertised update permission for $scopes", ({ scopes, expected }) => { | |
| 61 | expect( | |
| 62 | canMaintainEnvironment( | |
| 63 | { | |
| 64 | authenticated: true, | |
| 65 | auth: { | |
| 66 | policy: "remote-reachable", | |
| 67 | bootstrapMethods: [], | |
| 68 | sessionMethods: [], | |
| 69 | sessionCookieName: "session", | |
| 70 | serverUpdateScope: "environment:maintain", | |
| 71 | }, | |
| 72 | scopes, | |
| 73 | }, | |
| 74 | true, | |
| 75 | ), | |
| 76 | ).toBe(expected); | |
| 77 | }); | |
| 78 | ||
| 56 | 79 | it("requires remote desktop update support for desktop hosts", () => { |
| 57 | 80 | expect(supportsEnvironmentUpdate({})).toBe(false); |
| 58 | 81 | expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true); |
apps/mobile/src/features/settings/environment-maintenance.ts+2-1| ... | ... | @@ -16,7 +16,8 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect |
| 16 | 16 | return ( |
| 17 | 17 | connected && |
| 18 | 18 | session?.authenticated === true && |
| 19 | session.scopes?.includes(AuthOrchestrationOperateScope) === true | |
| 19 | session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === | |
| 20 | true | |
| 20 | 21 | ); |
| 21 | 22 | } |
| 22 | 23 |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+20| ... | ... | @@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 28 | 28 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 29 | 29 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 30 | 30 | import { |
| 31 | AuthOrchestrationOperateScope, | |
| 31 | 32 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 32 | 33 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 33 | 34 | resolveEnvironmentMachineKind, |
| ... | ... | @@ -133,6 +134,7 @@ import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-sha |
| 133 | 134 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 134 | 135 | import { serverEnvironment } from "../../state/server"; |
| 135 | 136 | import { fileRoutePathSegments } from "../files/filePath"; |
| 137 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 136 | 138 | |
| 137 | 139 | function NewTaskWorkspaceIcon(props: { |
| 138 | 140 | readonly workspaceMode: "local" | "worktree"; |
| ... | ... | @@ -219,6 +221,12 @@ export function NewTaskDraftScreen(props: { |
| 219 | 221 | connectedEnvironments.find( |
| 220 | 222 | (environment) => environment.environmentId === selectedProject.environmentId, |
| 221 | 223 | )?.connectionState === "connected"; |
| 224 | const canOperate = useEnvironmentScope( | |
| 225 | selectedProject?.environmentId ?? null, | |
| 226 | AuthOrchestrationOperateScope, | |
| 227 | ); | |
| 228 | const taskPermissionReason = | |
| 229 | environmentConnected && !canOperate ? "This connection cannot start tasks." : null; | |
| 222 | 230 | const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; |
| 223 | 231 | // A project added by cloning exists before its files do: the prompt can be |
| 224 | 232 | // written meanwhile, but Start waits for the clone. |
| ... | ... | @@ -1214,6 +1222,12 @@ export function NewTaskDraftScreen(props: { |
| 1214 | 1222 | if (!selectedProject || !draftKey) { |
| 1215 | 1223 | return; |
| 1216 | 1224 | } |
| 1225 | if ( | |
| 1226 | environmentConnected && | |
| 1227 | !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope) | |
| 1228 | ) { | |
| 1229 | return; | |
| 1230 | } | |
| 1217 | 1231 | const draft = getComposerDraftSnapshot(draftKey); |
| 1218 | 1232 | if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return; |
| 1219 | 1233 | // Read the latest explicit pick. Antigravity selections stay unchanged |
| ... | ... | @@ -1378,6 +1392,7 @@ export function NewTaskDraftScreen(props: { |
| 1378 | 1392 | const canStart = |
| 1379 | 1393 | !isImportingContext && |
| 1380 | 1394 | !cloneBlocksStart && |
| 1395 | taskPermissionReason === null && | |
| 1381 | 1396 | attachmentBlockReason === null && |
| 1382 | 1397 | !modelUnavailable && |
| 1383 | 1398 | Boolean(flow.selectedProject) && |
| ... | ... | @@ -1655,6 +1670,10 @@ export function NewTaskDraftScreen(props: { |
| 1655 | 1670 | ) : null} |
| 1656 | 1671 | {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null} |
| 1657 | 1672 | |
| 1673 | {taskPermissionReason ? ( | |
| 1674 | <Text className="px-3 py-2 text-xs text-muted-foreground">{taskPermissionReason}</Text> | |
| 1675 | ) : null} | |
| 1676 | ||
| 1658 | 1677 | {modelUnavailable ? ( |
| 1659 | 1678 | <Pressable |
| 1660 | 1679 | accessibilityRole="button" |
| ... | ... | @@ -1794,6 +1813,7 @@ export function NewTaskDraftScreen(props: { |
| 1794 | 1813 | {voicePresentation.showsSend ? ( |
| 1795 | 1814 | <ComposerActionButton |
| 1796 | 1815 | accessibilityLabel={ |
| 1816 | taskPermissionReason ?? | |
| 1797 | 1817 | attachmentBlockReason ?? |
| 1798 | 1818 | (cloneBlocksStart |
| 1799 | 1819 | ? projectClone === null || projectClone.phase === "running" |
apps/mobile/src/features/threads/PendingApprovalCard.tsx+7-1| ... | ... | @@ -10,6 +10,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 10 | 10 | import type { PendingApproval } from "../../lib/threadActivity"; |
| 11 | 11 | |
| 12 | 12 | export interface PendingApprovalCardProps { |
| 13 | readonly canOperateThread: boolean; | |
| 13 | 14 | readonly approval: PendingApproval; |
| 14 | 15 | readonly respondingApprovalId: RuntimeRequestId | null; |
| 15 | 16 | readonly onRespond: ( |
| ... | ... | @@ -66,11 +67,16 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 66 | 67 | ? "danger" |
| 67 | 68 | : "secondary" |
| 68 | 69 | } |
| 69 | disabled={disabled} | |
| 70 | disabled={disabled || !props.canOperateThread} | |
| 70 | 71 | onPress={() => void props.onRespond(props.approval.requestId, option.decision)} |
| 71 | 72 | /> |
| 72 | 73 | ))} |
| 73 | 74 | </View> |
| 75 | {!props.canOperateThread ? ( | |
| 76 | <Text className="font-sans text-xs text-adaptive-neutral-500-400"> | |
| 77 | This connection cannot respond to approvals. | |
| 78 | </Text> | |
| 79 | ) : null} | |
| 74 | 80 | </View> |
| 75 | 81 | ); |
| 76 | 82 | } |
apps/mobile/src/features/threads/PendingUserInputCard.tsx+8-1| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | } from "../../lib/threadActivity"; |
| 29 | 29 | |
| 30 | 30 | export interface PendingUserInputCardProps { |
| 31 | readonly canOperateThread: boolean; | |
| 31 | 32 | readonly pendingUserInput: PendingUserInput; |
| 32 | 33 | /** |
| 33 | 34 | * Constant while a request is pending (it reserves keyboard space), so the |
| ... | ... | @@ -200,6 +201,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 200 | 201 | icon="stop.fill" |
| 201 | 202 | variant="danger" |
| 202 | 203 | className="h-9 w-9" |
| 204 | disabled={!props.canOperateThread} | |
| 203 | 205 | onPress={props.onStopThread} |
| 204 | 206 | /> |
| 205 | 207 | ) : null} |
| ... | ... | @@ -342,7 +344,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 342 | 344 | label="Submit answers" |
| 343 | 345 | size="large" |
| 344 | 346 | tone={props.answers ? "primary" : "secondary"} |
| 345 | disabled={responseDisabled || props.answers === null} | |
| 347 | disabled={!props.canOperateThread || responseDisabled || props.answers === null} | |
| 346 | 348 | onPress={() => void props.onSubmit()} |
| 347 | 349 | /> |
| 348 | 350 | {props.pendingUserInput.dismissible ? ( |
| ... | ... | @@ -357,6 +359,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 357 | 359 | </Text> |
| 358 | 360 | </Pressable> |
| 359 | 361 | ) : null} |
| 362 | {!props.canOperateThread ? ( | |
| 363 | <Text className="font-sans text-xs text-foreground-tertiary"> | |
| 364 | This connection cannot submit answers. | |
| 365 | </Text> | |
| 366 | ) : null} | |
| 360 | 367 | </Animated.View> |
| 361 | 368 | ) : null; |
| 362 | 369 | return ( |
apps/mobile/src/features/threads/ThreadComposer.tsx+10| ... | ... | @@ -140,6 +140,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; |
| 140 | 140 | export const COMPOSER_EXPANDED_CHROME = 156; |
| 141 | 141 | |
| 142 | 142 | export interface ThreadComposerProps { |
| 143 | readonly canOperateThread: boolean; | |
| 143 | 144 | readonly draftMessage: string; |
| 144 | 145 | readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; |
| 145 | 146 | readonly placeholder: string; |
| ... | ... | @@ -539,6 +540,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 539 | 540 | (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? |
| 540 | 541 | attachmentBlockReason; |
| 541 | 542 | const canSend = |
| 543 | (props.canOperateThread || props.connectionState !== "connected") && | |
| 542 | 544 | hasContent && |
| 543 | 545 | !contextImports[composerDraftKey] && |
| 544 | 546 | !voiceInput.blocksSubmission && |
| ... | ... | @@ -1056,6 +1058,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 1056 | 1058 | accessibilityLabel="Stop agent" |
| 1057 | 1059 | icon="stop.fill" |
| 1058 | 1060 | variant="danger" |
| 1061 | disabled={!props.canOperateThread} | |
| 1059 | 1062 | onPress={props.onStopThread} |
| 1060 | 1063 | /> |
| 1061 | 1064 | ) : ( |
| ... | ... | @@ -1150,6 +1153,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 1150 | 1153 | accessibilityLabel="Stop agent" |
| 1151 | 1154 | icon="stop.fill" |
| 1152 | 1155 | variant="danger" |
| 1156 | disabled={!props.canOperateThread} | |
| 1153 | 1157 | onPress={props.onStopThread} |
| 1154 | 1158 | /> |
| 1155 | 1159 | ) : voicePresentation.showsSend ? ( |
| ... | ... | @@ -1165,6 +1169,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 1165 | 1169 | </ComposerDictationToolbar> |
| 1166 | 1170 | </Animated.View> |
| 1167 | 1171 | </ComposerSurface> |
| 1172 | ||
| 1173 | {props.connectionState === "connected" && !props.canOperateThread ? ( | |
| 1174 | <Text className="pt-2 text-xs text-foreground-muted"> | |
| 1175 | This connection cannot control this task. You can still edit your draft. | |
| 1176 | </Text> | |
| 1177 | ) : null} | |
| 1168 | 1178 | </Animated.View> |
| 1169 | 1179 | |
| 1170 | 1180 | <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} /> |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4| ... | ... | @@ -148,6 +148,7 @@ import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; |
| 148 | 148 | export interface ThreadDetailScreenProps { |
| 149 | 149 | readonly worktreeSetup?: WorktreeSetupCardProps | null; |
| 150 | 150 | readonly setupWorkingStartedAt?: string | null; |
| 151 | readonly canOperateThread: boolean; | |
| 151 | 152 | readonly selectedThread: EnvironmentThreadShell; |
| 152 | 153 | readonly contentPresentation: ThreadContentPresentation; |
| 153 | 154 | readonly screenTone: StatusTone; |
| ... | ... | @@ -1297,6 +1298,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1297 | 1298 | > |
| 1298 | 1299 | {props.activePendingApproval ? ( |
| 1299 | 1300 | <PendingApprovalCard |
| 1301 | canOperateThread={props.canOperateThread} | |
| 1300 | 1302 | approval={props.activePendingApproval} |
| 1301 | 1303 | respondingApprovalId={props.respondingApprovalId} |
| 1302 | 1304 | onRespond={props.onRespondToApproval} |
| ... | ... | @@ -1304,6 +1306,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1304 | 1306 | ) : null} |
| 1305 | 1307 | {props.activePendingUserInput ? ( |
| 1306 | 1308 | <PendingUserInputCard |
| 1309 | canOperateThread={props.canOperateThread} | |
| 1307 | 1310 | pendingUserInput={props.activePendingUserInput} |
| 1308 | 1311 | maxHeight={pendingUserInputMaxHeight} |
| 1309 | 1312 | collapsed={userInputCollapsed} |
| ... | ... | @@ -1371,6 +1374,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1371 | 1374 | ) : ( |
| 1372 | 1375 | <> |
| 1373 | 1376 | <ThreadComposer |
| 1377 | canOperateThread={props.canOperateThread} | |
| 1374 | 1378 | reportedModelSelection={reportedModelSelection} |
| 1375 | 1379 | editorRef={composerEditorRef} |
| 1376 | 1380 | draftMessage={props.draftMessage} |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+12-1| ... | ... | @@ -15,11 +15,13 @@ import { |
| 15 | 15 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 16 | 16 | import * as Option from "effect/Option"; |
| 17 | 17 | import { |
| 18 | AuthOrchestrationOperateScope, | |
| 18 | 19 | DEFAULT_SERVER_SETTINGS, |
| 19 | 20 | EnvironmentId, |
| 20 | 21 | ThreadId, |
| 21 | 22 | type ProjectScript, |
| 22 | 23 | } from "@t3tools/contracts"; |
| 24 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 23 | 25 | import { |
| 24 | 26 | projectScriptCwd, |
| 25 | 27 | projectScriptRuntimeEnv, |
| ... | ... | @@ -333,6 +335,10 @@ function ThreadRouteContent( |
| 333 | 335 | selectedThreadProject, |
| 334 | 336 | selectedEnvironmentConnection, |
| 335 | 337 | } = useThreadSelection(); |
| 338 | const canOperateThread = useEnvironmentScope( | |
| 339 | selectedThread?.environmentId ?? null, | |
| 340 | AuthOrchestrationOperateScope, | |
| 341 | ); | |
| 336 | 342 | const selectedThreadDetailState = props.selectedThreadDetailState; |
| 337 | 343 | const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); |
| 338 | 344 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| ... | ... | @@ -673,7 +679,11 @@ function ThreadRouteContent( |
| 673 | 679 | void navigation.navigate("Connections"); |
| 674 | 680 | }, [navigation]); |
| 675 | 681 | const handleStopThread = useCallback(() => { |
| 676 | if (!selectedThread || composer.interruptibleRunId === null) { | |
| 682 | if ( | |
| 683 | !selectedThread || | |
| 684 | !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) || | |
| 685 | composer.interruptibleRunId === null | |
| 686 | ) { | |
| 677 | 687 | return; |
| 678 | 688 | } |
| 679 | 689 | return interruptThreadTurn({ |
| ... | ... | @@ -988,6 +998,7 @@ function ThreadRouteContent( |
| 988 | 998 | |
| 989 | 999 | <View className="flex-1 bg-screen android:overflow-hidden android:rounded-t-[28px] android:bg-thread-canvas"> |
| 990 | 1000 | <ThreadDetailScreen |
| 1001 | canOperateThread={canOperateThread} | |
| 991 | 1002 | selectedThread={selectedThreadWithDraftSettings ?? selectedThread} |
| 992 | 1003 | contentPresentation={contentPresentation} |
| 993 | 1004 | screenTone={connectionTone(routeConnectionState)} |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+8-3| ... | ... | @@ -17,7 +17,7 @@ import type { |
| 17 | 17 | EnvironmentThreadShell, |
| 18 | 18 | } from "@t3tools/client-runtime/state/shell"; |
| 19 | 19 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 20 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 20 | import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 21 | 21 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; |
| 22 | 22 | import type { MenuAction } from "@react-native-menu/menu"; |
| 23 | 23 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; |
| ... | ... | @@ -34,6 +34,7 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco |
| 34 | 34 | import { cn } from "../../lib/cn"; |
| 35 | 35 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 36 | 36 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 37 | import { useEnvironmentScope } from "../../state/session"; | |
| 37 | 38 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 38 | 39 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 39 | 40 | import { useSwipeRowDormant } from "../home/swipe-row-activation"; |
| ... | ... | @@ -600,6 +601,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 600 | 601 | |
| 601 | 602 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 602 | 603 | const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); |
| 604 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); | |
| 603 | 605 | const handleRegenerateTitle = useCallback( |
| 604 | 606 | () => onRegenerateThreadTitle(thread), |
| 605 | 607 | [onRegenerateThreadTitle, thread], |
| ... | ... | @@ -917,8 +919,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 917 | 919 | : null, |
| 918 | 920 | [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], |
| 919 | 921 | ); |
| 920 | const swipeAccessibilityHint = | |
| 921 | secondaryAction === null | |
| 922 | const swipeAccessibilityHint = !canOperateThread | |
| 923 | ? "Opens the thread" | |
| 924 | : secondaryAction === null | |
| 922 | 925 | ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` |
| 923 | 926 | : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; |
| 924 | 927 | |
| ... | ... | @@ -1224,6 +1227,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1224 | 1227 | </RowPressable> |
| 1225 | 1228 | ); |
| 1226 | 1229 | |
| 1230 | if (!canOperateThread) return rowContent(() => {}); | |
| 1231 | ||
| 1227 | 1232 | return ( |
| 1228 | 1233 | <View collapsable={false}> |
| 1229 | 1234 | {customSnoozeOpen && ( |
apps/mobile/src/features/usage/UsageLimitsSection.test.ts+4| ... | ... | @@ -9,6 +9,10 @@ const state = vi.hoisted(() => ({ |
| 9 | 9 | autoRefresh: async () => {}, |
| 10 | 10 | refreshingRef: { current: false }, |
| 11 | 11 | })); |
| 12 | vi.mock("../../state/session", () => ({ | |
| 13 | useEnvironmentScope: () => true, | |
| 14 | readEnvironmentScope: () => true, | |
| 15 | })); | |
| 12 | 16 | vi.mock("react", () => ({ |
| 13 | 17 | useState: (initial: unknown) => { |
| 14 | 18 | const index = state.cursor++; |
apps/mobile/src/features/usage/UsageLimitsSection.tsx+22-12| ... | ... | @@ -1,12 +1,13 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { | |
| 3 | EnvironmentId, | |
| 4 | ProviderConsumeResetCreditOutcome, | |
| 5 | ProviderConsumeResetCreditInput, | |
| 6 | ServerProvider, | |
| 7 | ServerProviderResetCredits, | |
| 8 | ServerProviderUsageWindow, | |
| 9 | UsageProviderKind, | |
| 2 | import { | |
| 3 | AuthProvidersManageScope, | |
| 4 | type EnvironmentId, | |
| 5 | type ProviderConsumeResetCreditOutcome, | |
| 6 | type ProviderConsumeResetCreditInput, | |
| 7 | type ServerProvider, | |
| 8 | type ServerProviderResetCredits, | |
| 9 | type ServerProviderUsageWindow, | |
| 10 | type UsageProviderKind, | |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import { |
| 12 | 13 | elapsedShare, |
| ... | ... | @@ -24,6 +25,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 24 | 25 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 25 | 26 | import { environmentPresentations } from "../../state/presentation"; |
| 26 | 27 | import { serverEnvironment } from "../../state/server"; |
| 28 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 27 | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | 30 | import { useProviderColors } from "./usageProviders"; |
| 29 | 31 | |
| ... | ... | @@ -207,6 +209,7 @@ export function ResetCredits(props: { |
| 207 | 209 | readonly dense?: boolean; |
| 208 | 210 | }) { |
| 209 | 211 | const { environmentId, input, credits, now, dense = false } = props; |
| 212 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 210 | 213 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { |
| 211 | 214 | reportFailure: false, |
| 212 | 215 | }); |
| ... | ... | @@ -225,6 +228,7 @@ export function ResetCredits(props: { |
| 225 | 228 | }`; |
| 226 | 229 | |
| 227 | 230 | const redeem = async () => { |
| 231 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 228 | 232 | setBusy(true); |
| 229 | 233 | setStatus(null); |
| 230 | 234 | const result = await consume({ environmentId, input }); |
| ... | ... | @@ -241,6 +245,7 @@ export function ResetCredits(props: { |
| 241 | 245 | }; |
| 242 | 246 | |
| 243 | 247 | const confirm = () => { |
| 248 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 244 | 249 | Alert.alert( |
| 245 | 250 | "Use a reset credit?", |
| 246 | 251 | "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", |
| ... | ... | @@ -257,13 +262,13 @@ export function ResetCredits(props: { |
| 257 | 262 | {credits.availableCount > 0 ? ( |
| 258 | 263 | <Pressable |
| 259 | 264 | accessibilityRole="button" |
| 260 | accessibilityState={{ disabled: busy }} | |
| 261 | disabled={busy} | |
| 265 | accessibilityState={{ disabled: busy || !canManageProviders }} | |
| 266 | disabled={busy || !canManageProviders} | |
| 262 | 267 | onPress={confirm} |
| 263 | 268 | className={ |
| 264 | 269 | dense |
| 265 | ? "rounded-full bg-subtle-strong px-2.5 py-1" | |
| 266 | : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5" | |
| 270 | ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]" | |
| 271 | : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]" | |
| 267 | 272 | } |
| 268 | 273 | > |
| 269 | 274 | <Text |
| ... | ... | @@ -277,6 +282,11 @@ export function ResetCredits(props: { |
| 277 | 282 | </Text> |
| 278 | 283 | </Pressable> |
| 279 | 284 | ) : null} |
| 285 | {!canManageProviders ? ( | |
| 286 | <Text className="text-xs text-foreground-tertiary"> | |
| 287 | This connection cannot manage provider accounts. | |
| 288 | </Text> | |
| 289 | ) : null} | |
| 280 | 290 | {status ? <Text className="text-sm text-foreground">{status}</Text> : null} |
| 281 | 291 | </View> |
| 282 | 292 | ); |
apps/mobile/src/lib/attachmentUpload.test.ts+77| ... | ... | @@ -3,6 +3,7 @@ import * as Option from "effect/Option"; |
| 3 | 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | const mocks = vi.hoisted(() => ({ |
| 6 | canOperate: true, | |
| 6 | 7 | documentUri: "file:///documents", |
| 7 | 8 | createAssetUrl: vi.fn(), |
| 8 | 9 | createUploadUrl: Symbol("create-upload-url"), |
| ... | ... | @@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({ |
| 48 | 49 | })); |
| 49 | 50 | |
| 50 | 51 | vi.mock("../state/session", () => ({ |
| 52 | readEnvironmentScope: () => mocks.canOperate, | |
| 51 | 53 | environmentSession: { |
| 52 | 54 | preparedConnectionValueAtom: () => mocks.preparedConnection, |
| 53 | 55 | }, |
| ... | ... | @@ -194,6 +196,7 @@ function removeCallsFor(attachmentId: string): number { |
| 194 | 196 | |
| 195 | 197 | describe("prepareTurnAttachments", () => { |
| 196 | 198 | beforeEach(() => { |
| 199 | mocks.canOperate = true; | |
| 197 | 200 | mocks.documentUri = "file:///documents"; |
| 198 | 201 | mocks.createAssetUrl.mockReset(); |
| 199 | 202 | mocks.createAssetUrl.mockImplementation((target: unknown) => target); |
| ... | ... | @@ -222,6 +225,80 @@ describe("prepareTurnAttachments", () => { |
| 222 | 225 | mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} }); |
| 223 | 226 | }); |
| 224 | 227 | |
| 228 | it("does not mint or transfer attachments without task operation access", async () => { | |
| 229 | mocks.canOperate = false; | |
| 230 | await expect( | |
| 231 | prepareTurnAttachments({ | |
| 232 | environmentId, | |
| 233 | attachments: [fileBackedImage], | |
| 234 | supportsImageUploads: true, | |
| 235 | }), | |
| 236 | ).rejects.toThrow("cannot upload attachments"); | |
| 237 | expect(mocks.runAtomCommand).not.toHaveBeenCalled(); | |
| 238 | expect(mocks.upload).not.toHaveBeenCalled(); | |
| 239 | }); | |
| 240 | ||
| 241 | it("rechecks access after a signed URL is minted before sending any bytes", async () => { | |
| 242 | mocks.runAtomCommand.mockImplementationOnce(async () => { | |
| 243 | mocks.canOperate = false; | |
| 244 | return { | |
| 245 | _tag: "Success", | |
| 246 | value: { | |
| 247 | attachmentId: MINTED_ID, | |
| 248 | relativeUrl: "/api/attachments/upload/signed", | |
| 249 | expiresAt: 1, | |
| 250 | }, | |
| 251 | }; | |
| 252 | }); | |
| 253 | const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 254 | try { | |
| 255 | await expect( | |
| 256 | prepareTurnAttachments({ | |
| 257 | environmentId, | |
| 258 | attachments: [fileBackedImage], | |
| 259 | supportsImageUploads: true, | |
| 260 | }), | |
| 261 | ).rejects.toThrow("cannot upload attachments"); | |
| 262 | expect(mocks.upload).not.toHaveBeenCalled(); | |
| 263 | expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); | |
| 264 | } finally { | |
| 265 | warning.mockRestore(); | |
| 266 | } | |
| 267 | }); | |
| 268 | ||
| 269 | it("does not replace an expired upload when access was revoked during verification", async () => { | |
| 270 | mocks.executeAtomQuery.mockImplementationOnce(async () => { | |
| 271 | mocks.canOperate = false; | |
| 272 | return { _tag: "Failure", error: { _tag: "AssetAttachmentNotFoundError" } }; | |
| 273 | }); | |
| 274 | await expect( | |
| 275 | prepareTurnAttachments({ | |
| 276 | environmentId, | |
| 277 | attachments: [ | |
| 278 | { | |
| 279 | ...fileBackedImage, | |
| 280 | uploadedAttachmentId: MINTED_ID, | |
| 281 | uploadEnvironmentId: environmentId, | |
| 282 | }, | |
| 283 | ], | |
| 284 | supportsImageUploads: true, | |
| 285 | }), | |
| 286 | ).rejects.toThrow("cannot upload attachments"); | |
| 287 | expect(mocks.runAtomCommand).not.toHaveBeenCalled(); | |
| 288 | expect(mocks.upload).not.toHaveBeenCalled(); | |
| 289 | }); | |
| 290 | ||
| 291 | it("rechecks deletion permission before retrying a failed cleanup", async () => { | |
| 292 | mocks.runAtomCommand.mockImplementationOnce(async () => { | |
| 293 | mocks.canOperate = false; | |
| 294 | return { _tag: "Failure", error: new Error("Retry cleanup") }; | |
| 295 | }); | |
| 296 | await expect(releasePendingAttachmentUploads(environmentId, [MINTED_ID])).rejects.toThrow( | |
| 297 | "cannot delete pending attachments", | |
| 298 | ); | |
| 299 | expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); | |
| 300 | }); | |
| 301 | ||
| 225 | 302 | it("keeps existing image attachments on the legacy wire path", async () => { |
| 226 | 303 | const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] }); |
| 227 | 304 |
apps/mobile/src/lib/attachmentUpload.ts+23-2| ... | ... | @@ -13,13 +13,16 @@ import type { |
| 13 | 13 | EnvironmentId, |
| 14 | 14 | UploadChatImageAttachment, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; | |
| 16 | import { | |
| 17 | AuthOrchestrationOperateScope, | |
| 18 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, | |
| 19 | } from "@t3tools/contracts"; | |
| 17 | 20 | import * as Option from "effect/Option"; |
| 18 | 21 | |
| 19 | 22 | import { appAtomRegistry } from "../state/atom-registry"; |
| 20 | 23 | import { assetEnvironment } from "../state/assets"; |
| 21 | 24 | import { attachmentEnvironment } from "../state/attachments"; |
| 22 | import { environmentSession } from "../state/session"; | |
| 25 | import { environmentSession, readEnvironmentScope } from "../state/session"; | |
| 23 | 26 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 24 | 27 | import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; |
| 25 | 28 | import { |
| ... | ... | @@ -109,6 +112,9 @@ export async function releasePendingAttachmentUploads( |
| 109 | 112 | attachmentIds: ReadonlyArray<string>, |
| 110 | 113 | ): Promise<void> { |
| 111 | 114 | const deleteOnce = async (attachmentId: string): Promise<boolean> => { |
| 115 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { | |
| 116 | throw new Error("This connection cannot delete pending attachments."); | |
| 117 | } | |
| 112 | 118 | const result = await runAtomCommand( |
| 113 | 119 | appAtomRegistry, |
| 114 | 120 | attachmentEnvironment.remove, |
| ... | ... | @@ -255,6 +261,7 @@ async function composerImageAttachmentDataUrl( |
| 255 | 261 | } |
| 256 | 262 | |
| 257 | 263 | async function uploadFileBytes( |
| 264 | environmentId: EnvironmentId, | |
| 258 | 265 | attachment: DraftComposerAttachment, |
| 259 | 266 | url: string, |
| 260 | 267 | signal: AbortSignal, |
| ... | ... | @@ -262,6 +269,9 @@ async function uploadFileBytes( |
| 262 | 269 | ): Promise<void> { |
| 263 | 270 | const { File, Paths, UploadType } = await import("expo-file-system"); |
| 264 | 271 | if (signal.aborted) throw new Error("Upload cancelled."); |
| 272 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { | |
| 273 | throw new Error("This connection cannot upload attachments."); | |
| 274 | } | |
| 265 | 275 | // Legacy image drafts persisted inline bytes and stage them in a temp cache |
| 266 | 276 | // file for the native uploader. Everything else uploads its owned copy. |
| 267 | 277 | const fileUri = attachment.fileUri; |
| ... | ... | @@ -349,6 +359,13 @@ export async function prepareTurnAttachments(input: { |
| 349 | 359 | } |
| 350 | 360 | } |
| 351 | 361 | |
| 362 | const requireUploadAccess = () => { | |
| 363 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { | |
| 364 | throw new Error("This connection cannot upload attachments."); | |
| 365 | } | |
| 366 | }; | |
| 367 | requireUploadAccess(); | |
| 368 | ||
| 352 | 369 | const connection = appAtomRegistry.get( |
| 353 | 370 | environmentSession.preparedConnectionValueAtom(environmentId), |
| 354 | 371 | ); |
| ... | ... | @@ -365,6 +382,7 @@ export async function prepareTurnAttachments(input: { |
| 365 | 382 | try { |
| 366 | 383 | for (const attachment of input.attachments) { |
| 367 | 384 | if (controller.signal.aborted) throw new Error("Upload cancelled."); |
| 385 | requireUploadAccess(); | |
| 368 | 386 | if (attachment.type === "image" && !input.supportsImageUploads) { |
| 369 | 387 | uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment]))); |
| 370 | 388 | continue; |
| ... | ... | @@ -393,6 +411,7 @@ export async function prepareTurnAttachments(input: { |
| 393 | 411 | // "missing": the pending upload expired, upload the bytes again. |
| 394 | 412 | } |
| 395 | 413 | |
| 414 | requireUploadAccess(); | |
| 396 | 415 | const result = await runAttachmentUploadCycle({ |
| 397 | 416 | registry: appAtomRegistry, |
| 398 | 417 | createUploadUrl: attachmentEnvironment.createUploadUrl, |
| ... | ... | @@ -402,6 +421,7 @@ export async function prepareTurnAttachments(input: { |
| 402 | 421 | // Read the connection at transfer time: the environment may have |
| 403 | 422 | // reconnected on a new base URL since this cycle started. |
| 404 | 423 | resolveUploadUrl: (relativeUrl) => { |
| 424 | requireUploadAccess(); | |
| 405 | 425 | const currentConnection = appAtomRegistry.get( |
| 406 | 426 | environmentSession.preparedConnectionValueAtom(environmentId), |
| 407 | 427 | ); |
| ... | ... | @@ -411,6 +431,7 @@ export async function prepareTurnAttachments(input: { |
| 411 | 431 | }, |
| 412 | 432 | transport: (url) => ({ |
| 413 | 433 | done: uploadFileBytes( |
| 434 | environmentId, | |
| 414 | 435 | attachment, |
| 415 | 436 | url, |
| 416 | 437 | controller.signal, |
apps/mobile/src/state/attachments.test.ts created+99| ... | ... | @@ -0,0 +1,99 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | EnvironmentAuthorizationError, | |
| 4 | EnvironmentId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { runAttachmentUploadCycle } from "@t3tools/client-runtime/state/attachments"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import { AsyncResult, AtomRegistry } from "effect/reactivity"; | |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | const state = vi.hoisted(() => ({ | |
| 12 | grantedEnvironments: new Set<string>(), | |
| 13 | create: vi.fn(), | |
| 14 | remove: vi.fn(), | |
| 15 | })); | |
| 16 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); | |
| 17 | vi.mock("./session", () => ({ | |
| 18 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 19 | scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), | |
| 20 | })); | |
| 21 | vi.mock("@t3tools/client-runtime/state/attachments", async (importOriginal) => ({ | |
| 22 | ...(await importOriginal<typeof import("@t3tools/client-runtime/state/attachments")>()), | |
| 23 | createAttachmentEnvironmentAtoms: () => ({ | |
| 24 | createUploadUrl: { label: "create", run: state.create }, | |
| 25 | remove: { label: "remove", run: state.remove }, | |
| 26 | }), | |
| 27 | })); | |
| 28 | ||
| 29 | import { attachmentEnvironment } from "./attachments"; | |
| 30 | ||
| 31 | const environmentId = EnvironmentId.make("secondary"); | |
| 32 | const registry = AtomRegistry.make(); | |
| 33 | beforeEach(() => { | |
| 34 | state.grantedEnvironments = new Set(["primary"]); | |
| 35 | state.create.mockReset().mockResolvedValue( | |
| 36 | AsyncResult.success({ | |
| 37 | attachmentId: "pending", | |
| 38 | relativeUrl: "/upload", | |
| 39 | expiresAt: 1, | |
| 40 | }), | |
| 41 | ); | |
| 42 | state.remove.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 43 | }); | |
| 44 | afterEach(() => { | |
| 45 | vi.restoreAllMocks(); | |
| 46 | }); | |
| 47 | ||
| 48 | describe("attachment mutation grants", () => { | |
| 49 | it("uses the attachment environment for both mint and deletion", async () => { | |
| 50 | const mintResult = await attachmentEnvironment.createUploadUrl.run(registry, { | |
| 51 | environmentId, | |
| 52 | input: { name: "image.png", mimeType: "image/png", sizeBytes: 1 }, | |
| 53 | }); | |
| 54 | const removeResult = await attachmentEnvironment.remove.run(registry, { | |
| 55 | environmentId, | |
| 56 | input: { attachmentId: "pending" }, | |
| 57 | }); | |
| 58 | for (const result of [mintResult, removeResult]) { | |
| 59 | expect(result._tag).toBe("Failure"); | |
| 60 | if (result._tag !== "Failure") throw new Error("Expected permission denial"); | |
| 61 | const error = Cause.squash<unknown>(result.cause); | |
| 62 | expect(error).toBeInstanceOf(EnvironmentAuthorizationError); | |
| 63 | expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); | |
| 64 | } | |
| 65 | expect(state.create).not.toHaveBeenCalled(); | |
| 66 | expect(state.remove).not.toHaveBeenCalled(); | |
| 67 | state.grantedEnvironments.add("secondary"); | |
| 68 | expect( | |
| 69 | ( | |
| 70 | await attachmentEnvironment.remove.run(registry, { | |
| 71 | environmentId, | |
| 72 | input: { attachmentId: "pending" }, | |
| 73 | }) | |
| 74 | )._tag, | |
| 75 | ).toBe("Success"); | |
| 76 | expect(state.remove).toHaveBeenCalledOnce(); | |
| 77 | }); | |
| 78 | ||
| 79 | it("blocks the upload cycle's asynchronous cleanup after grant revocation", async () => { | |
| 80 | state.grantedEnvironments.add("secondary"); | |
| 81 | const transport = vi.fn(); | |
| 82 | const result = await runAttachmentUploadCycle({ | |
| 83 | registry, | |
| 84 | ...attachmentEnvironment, | |
| 85 | environmentId, | |
| 86 | upload: { name: "image.png", mimeType: "image/png", sizeBytes: 1 }, | |
| 87 | resolveUploadUrl: () => "https://example.test/upload", | |
| 88 | transport, | |
| 89 | onMinted: () => { | |
| 90 | state.grantedEnvironments.delete("secondary"); | |
| 91 | return "cancel"; | |
| 92 | }, | |
| 93 | }); | |
| 94 | expect(result.status).toBe("cancelled"); | |
| 95 | expect(state.create).toHaveBeenCalledOnce(); | |
| 96 | expect(state.remove).not.toHaveBeenCalled(); | |
| 97 | expect(transport).not.toHaveBeenCalled(); | |
| 98 | }); | |
| 99 | }); |
apps/mobile/src/state/attachments.ts+35-1| ... | ... | @@ -1,5 +1,39 @@ |
| 1 | 1 | import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments"; |
| 2 | import type { AtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | import { | |
| 4 | AuthOrchestrationOperateScope, | |
| 5 | EnvironmentAuthorizationError, | |
| 6 | type EnvironmentId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Cause from "effect/Cause"; | |
| 9 | import { AsyncResult } from "effect/reactivity"; | |
| 2 | 10 | |
| 3 | 11 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 12 | import { readEnvironmentScope } from "./session"; | |
| 4 | 13 | |
| 5 | export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime); | |
| 14 | function requireAttachmentWriteAccess<W extends { readonly environmentId: EnvironmentId }, A, E>( | |
| 15 | command: AtomCommand<W, A, E>, | |
| 16 | ): AtomCommand<W, A, E | EnvironmentAuthorizationError> { | |
| 17 | return { | |
| 18 | ...command, | |
| 19 | run: async (registry, input) => { | |
| 20 | if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) { | |
| 21 | return AsyncResult.failure( | |
| 22 | Cause.fail( | |
| 23 | new EnvironmentAuthorizationError({ | |
| 24 | message: "This connection cannot change attachments.", | |
| 25 | requiredScope: AuthOrchestrationOperateScope, | |
| 26 | }), | |
| 27 | ), | |
| 28 | ); | |
| 29 | } | |
| 30 | return command.run(registry, input); | |
| 31 | }, | |
| 32 | }; | |
| 33 | } | |
| 34 | ||
| 35 | const commands = createAttachmentEnvironmentAtoms(connectionAtomRuntime); | |
| 36 | export const attachmentEnvironment = { | |
| 37 | createUploadUrl: requireAttachmentWriteAccess(commands.createUploadUrl), | |
| 38 | remove: requireAttachmentWriteAccess(commands.remove), | |
| 39 | }; |
apps/mobile/src/state/composer-attachment-uploads.ts+14-3| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 3 | import { Atom } from "effect/reactivity"; |
| 4 | 4 | import { useEffect, useRef } from "react"; |
| 5 | 5 | |
| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | setComposerDraftAttachmentUpload, |
| 25 | 25 | } from "./use-composer-drafts"; |
| 26 | 26 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; |
| 27 | import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; | |
| 27 | 28 | |
| 28 | 29 | export { |
| 29 | 30 | composerAttachmentUploadBlockReason, |
| ... | ... | @@ -48,6 +49,7 @@ export function useComposerAttachmentUploadState( |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | 51 | export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { |
| 52 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 51 | 53 | uploadQueue?.retry(environmentId, attachmentId); |
| 52 | 54 | } |
| 53 | 55 | |
| ... | ... | @@ -57,6 +59,10 @@ export function useComposerAttachmentUploadWorker() { |
| 57 | 59 | const queuedMessages = useThreadOutboxMessages(); |
| 58 | 60 | const serverConfigs = useServerConfigs(); |
| 59 | 61 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 62 | const operableEnvironments = useEnvironmentsWithScope( | |
| 63 | connectedEnvironments, | |
| 64 | AuthOrchestrationOperateScope, | |
| 65 | ); | |
| 60 | 66 | const queueRef = useRef<ReturnType<typeof createComposerAttachmentUploadQueue> | null>(null); |
| 61 | 67 | |
| 62 | 68 | useEffect(() => { |
| ... | ... | @@ -117,7 +123,12 @@ export function useComposerAttachmentUploadWorker() { |
| 117 | 123 | ); |
| 118 | 124 | const requests = Object.entries(drafts).flatMap(([key, draft]) => { |
| 119 | 125 | const environmentId = composerDraftEnvironmentId(key, queued, draft); |
| 120 | if (environmentId === null || !connected.has(environmentId)) return []; | |
| 126 | if ( | |
| 127 | environmentId === null || | |
| 128 | !connected.has(environmentId) || | |
| 129 | !operableEnvironments.has(environmentId) | |
| 130 | ) | |
| 131 | return []; | |
| 121 | 132 | return draft.attachments |
| 122 | 133 | .filter((attachment) => |
| 123 | 134 | canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), |
| ... | ... | @@ -125,5 +136,5 @@ export function useComposerAttachmentUploadWorker() { |
| 125 | 136 | .map((attachment) => ({ environmentId, attachment })); |
| 126 | 137 | }); |
| 127 | 138 | queueRef.current?.sync(requests); |
| 128 | }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]); | |
| 139 | }, [connectedEnvironments, drafts, operableEnvironments, queuedMessages, serverConfigs]); | |
| 129 | 140 | } |
apps/mobile/src/state/session.test.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | EnvironmentId, | |
| 4 | type AuthSessionState, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Cause from "effect/Cause"; | |
| 7 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 8 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | const harness = vi.hoisted(() => ({ lastAtom: null as Atom.Atom<unknown> | null })); | |
| 11 | vi.mock("react", () => ({ useMemo: <A>(factory: () => A) => factory() })); | |
| 12 | vi.mock("@effect/atom-react", async () => { | |
| 13 | const { appAtomRegistry } = await import("./atom-registry"); | |
| 14 | return { | |
| 15 | useAtomValue: <A>(atom: Atom.Atom<A>) => { | |
| 16 | harness.lastAtom = atom; | |
| 17 | return appAtomRegistry.get(atom); | |
| 18 | }, | |
| 19 | }; | |
| 20 | }); | |
| 21 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); | |
| 22 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | |
| 23 | createEnvironmentSessionAtoms: () => ({ | |
| 24 | sessionStateAtom: Atom.family((_id: EnvironmentId) => | |
| 25 | Atom.make(AsyncResult.initial<AuthSessionState, Error>()).pipe(Atom.keepAlive), | |
| 26 | ), | |
| 27 | }), | |
| 28 | })); | |
| 29 | ||
| 30 | import { appAtomRegistry } from "./atom-registry"; | |
| 31 | import { | |
| 32 | environmentSession, | |
| 33 | readEnvironmentScope, | |
| 34 | useEnvironmentScope, | |
| 35 | useEnvironmentsWithScope, | |
| 36 | } from "./session"; | |
| 37 | ||
| 38 | const primary = EnvironmentId.make("primary"); | |
| 39 | const secondary = EnvironmentId.make("secondary"); | |
| 40 | const session = (canOperate: boolean): AuthSessionState => ({ | |
| 41 | authenticated: true, | |
| 42 | scopes: canOperate ? [AuthOrchestrationOperateScope] : [], | |
| 43 | auth: { | |
| 44 | policy: "remote-reachable", | |
| 45 | bootstrapMethods: [], | |
| 46 | sessionMethods: [], | |
| 47 | sessionCookieName: "session", | |
| 48 | }, | |
| 49 | }); | |
| 50 | // The production atom is read-only; the fake session source is writable. | |
| 51 | const source = (id: EnvironmentId) => | |
| 52 | environmentSession.sessionStateAtom(id) as unknown as Atom.Writable< | |
| 53 | AsyncResult.AsyncResult<AuthSessionState, Error> | |
| 54 | >; | |
| 55 | const releases: Array<() => void> = []; | |
| 56 | beforeEach(() => { | |
| 57 | harness.lastAtom = null; | |
| 58 | appAtomRegistry.set(source(primary), AsyncResult.success(session(true))); | |
| 59 | appAtomRegistry.set(source(secondary), AsyncResult.initial()); | |
| 60 | }); | |
| 61 | afterEach(() => { | |
| 62 | releases.splice(0).forEach((release) => release()); | |
| 63 | }); | |
| 64 | ||
| 65 | it("keeps pending and denied environments out of worker grants while retaining valid cached access", () => { | |
| 66 | expect(useEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false); | |
| 67 | expect(readEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false); | |
| 68 | const environments = [{ environmentId: primary }, { environmentId: secondary }]; | |
| 69 | expect(useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope)).toEqual( | |
| 70 | new Set([primary]), | |
| 71 | ); | |
| 72 | appAtomRegistry.set(source(secondary), AsyncResult.waiting(AsyncResult.success(session(true)))); | |
| 73 | expect(readEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(true); | |
| 74 | expect(useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope)).toEqual( | |
| 75 | new Set([primary, secondary]), | |
| 76 | ); | |
| 77 | appAtomRegistry.set(source(secondary), AsyncResult.success(session(false))); | |
| 78 | expect(useEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false); | |
| 79 | }); | |
| 80 | ||
| 81 | it("reacts to grant revocation, failure, and regrant without a connection list change", () => { | |
| 82 | const environments = [{ environmentId: primary }, { environmentId: secondary }]; | |
| 83 | useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope); | |
| 84 | const observed = harness.lastAtom; | |
| 85 | if (!observed) throw new Error("Missing worker grant atom"); | |
| 86 | const changes: unknown[] = []; | |
| 87 | releases.push(appAtomRegistry.subscribe(observed, (value) => changes.push(value))); | |
| 88 | appAtomRegistry.set(source(secondary), AsyncResult.success(session(true))); | |
| 89 | expect(appAtomRegistry.get(observed)).toEqual(new Set([primary, secondary])); | |
| 90 | appAtomRegistry.set(source(secondary), AsyncResult.success(session(false))); | |
| 91 | expect(appAtomRegistry.get(observed)).toEqual(new Set([primary])); | |
| 92 | appAtomRegistry.set( | |
| 93 | source(primary), | |
| 94 | AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))), | |
| 95 | ); | |
| 96 | expect(appAtomRegistry.get(observed)).toEqual(new Set()); | |
| 97 | appAtomRegistry.set(source(secondary), AsyncResult.success(session(true))); | |
| 98 | expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary])); | |
| 99 | expect(changes).not.toHaveLength(0); | |
| 100 | }); |
apps/mobile/src/state/session.ts+60-2| ... | ... | @@ -1,13 +1,71 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; | |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | import { Atom } from "effect/reactivity"; | |
| 5 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 6 | import { useMemo } from "react"; | |
| 6 | 7 | |
| 7 | 8 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 9 | import { appAtomRegistry } from "./atom-registry"; | |
| 8 | 10 | |
| 9 | 11 | export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); |
| 10 | 12 | |
| 13 | const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>()); | |
| 14 | ||
| 15 | function sessionHasScope( | |
| 16 | result: AsyncResult.AsyncResult<AuthSessionState, unknown>, | |
| 17 | scope: AuthEnvironmentScope, | |
| 18 | ): boolean { | |
| 19 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 20 | return ( | |
| 21 | result._tag !== "Failure" && | |
| 22 | session?.authenticated === true && | |
| 23 | session.scopes?.includes(scope) === true | |
| 24 | ); | |
| 25 | } | |
| 26 | ||
| 27 | /** Uses the selected environment's grant, including cached scopes during a refresh. */ | |
| 28 | export function useEnvironmentScope( | |
| 29 | environmentId: EnvironmentId | null, | |
| 30 | scope: AuthEnvironmentScope, | |
| 31 | ): boolean { | |
| 32 | const result = useAtomValue( | |
| 33 | environmentId === null | |
| 34 | ? EMPTY_SESSION_STATE_ATOM | |
| 35 | : environmentSession.sessionStateAtom(environmentId), | |
| 36 | ); | |
| 37 | return sessionHasScope(result, scope); | |
| 38 | } | |
| 39 | ||
| 40 | /** Keeps background workers subscribed to each target's grant. */ | |
| 41 | export function useEnvironmentsWithScope( | |
| 42 | environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>, | |
| 43 | scope: AuthEnvironmentScope, | |
| 44 | ): ReadonlySet<EnvironmentId> { | |
| 45 | const permittedEnvironments = useMemo( | |
| 46 | () => | |
| 47 | Atom.make((get) => { | |
| 48 | const permitted = new Set<EnvironmentId>(); | |
| 49 | for (const { environmentId } of environments) { | |
| 50 | if (sessionHasScope(get(environmentSession.sessionStateAtom(environmentId)), scope)) { | |
| 51 | permitted.add(environmentId); | |
| 52 | } | |
| 53 | } | |
| 54 | return permitted; | |
| 55 | }), | |
| 56 | [environments, scope], | |
| 57 | ); | |
| 58 | return useAtomValue(permittedEnvironments); | |
| 59 | } | |
| 60 | ||
| 61 | export function readEnvironmentScope( | |
| 62 | environmentId: EnvironmentId, | |
| 63 | scope: AuthEnvironmentScope, | |
| 64 | ): boolean { | |
| 65 | const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); | |
| 66 | return sessionHasScope(result, scope); | |
| 67 | } | |
| 68 | ||
| 11 | 69 | const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( |
| 12 | 70 | Atom.withLabel("mobile-prepared-connection:empty"), |
| 13 | 71 | ); |
apps/mobile/src/state/thread-task-permissions.test.ts created+260| ... | ... | @@ -0,0 +1,260 @@ |
| 1 | import { AuthOrchestrationOperateScope, RuntimeRequestId } from "@t3tools/contracts"; | |
| 2 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | |
| 3 | import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; | |
| 4 | import { AsyncResult, type Atom } from "effect/reactivity"; | |
| 5 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | const state = vi.hoisted(() => ({ | |
| 8 | grantedEnvironments: new Set<string>(), | |
| 9 | connectionState: "connected", | |
| 10 | draft: { text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[] }, | |
| 11 | pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, | |
| 12 | enqueue: vi.fn(async () => undefined), | |
| 13 | clearDraft: vi.fn(), | |
| 14 | approve: vi.fn(), | |
| 15 | answer: vi.fn(), | |
| 16 | feedback: vi.fn(), | |
| 17 | updateSettings: vi.fn(), | |
| 18 | question: { | |
| 19 | id: "language", | |
| 20 | header: "Language", | |
| 21 | question: "Which language?", | |
| 22 | options: [{ label: "TypeScript", description: "Use the existing code" }], | |
| 23 | multiSelect: false, | |
| 24 | }, | |
| 25 | thread: { | |
| 26 | id: "thread", | |
| 27 | environmentId: "secondary", | |
| 28 | modelSelection: { instanceId: "codex", model: "gpt-5.4" }, | |
| 29 | runtimeMode: "full-access", | |
| 30 | interactionMode: "default", | |
| 31 | runtime: null, | |
| 32 | latestRun: null, | |
| 33 | }, | |
| 34 | })); | |
| 35 | ||
| 36 | vi.mock("react", () => ({ | |
| 37 | useCallback: <A>(callback: A) => callback, | |
| 38 | useEffect: () => {}, | |
| 39 | useRef: <A>(current: A) => ({ current }), | |
| 40 | useMemo: <A>(factory: () => A) => factory(), | |
| 41 | useState: <A>(initial: A | (() => A)) => [ | |
| 42 | typeof initial === "function" ? (initial as () => A)() : initial, | |
| 43 | vi.fn(), | |
| 44 | ], | |
| 45 | })); | |
| 46 | vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); | |
| 47 | vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); | |
| 48 | vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); | |
| 49 | vi.mock("./queued-run-edit", () => ({ | |
| 50 | useQueuedRunEdit: () => null, | |
| 51 | getQueuedRunEdit: () => null, | |
| 52 | })); | |
| 53 | vi.mock("./preferences", async () => { | |
| 54 | const { Atom, AsyncResult } = await import("effect/reactivity"); | |
| 55 | return { mobilePreferencesAtom: Atom.make(AsyncResult.success({})) }; | |
| 56 | }); | |
| 57 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | |
| 58 | vi.mock("@effect/atom-react", async () => { | |
| 59 | const { appAtomRegistry } = await import("./atom-registry"); | |
| 60 | return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) }; | |
| 61 | }); | |
| 62 | vi.mock("./entities", () => ({ useServerConfigs: () => new Map() })); | |
| 63 | vi.mock("./session", () => ({ | |
| 64 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 65 | scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), | |
| 66 | })); | |
| 67 | vi.mock("./use-thread-selection", () => ({ | |
| 68 | useThreadSelection: () => ({ | |
| 69 | selectedThread: state.thread, | |
| 70 | selectedThreadCreation: null, | |
| 71 | selectedEnvironmentRuntime: { | |
| 72 | connectionState: state.connectionState, | |
| 73 | serverConfig: { | |
| 74 | providers: [{ instanceId: "codex", driver: "codex" }], | |
| 75 | environment: { capabilities: {} }, | |
| 76 | }, | |
| 77 | }, | |
| 78 | }), | |
| 79 | })); | |
| 80 | vi.mock("./use-thread-detail", () => ({ | |
| 81 | useSelectedThreadProjection: () => null, | |
| 82 | useSelectedThreadVisibleTurnItems: () => [], | |
| 83 | useSelectedThreadPendingRequests: () => state.pendingRequests, | |
| 84 | })); | |
| 85 | vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command })); | |
| 86 | vi.mock("./threads", async () => { | |
| 87 | const { Atom } = await import("effect/reactivity"); | |
| 88 | return { | |
| 89 | environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) }, | |
| 90 | threadEnvironment: { | |
| 91 | respondToApproval: state.approve, | |
| 92 | respondToUserInput: state.answer, | |
| 93 | uploadFeedback: state.feedback, | |
| 94 | }, | |
| 95 | }; | |
| 96 | }); | |
| 97 | vi.mock("./use-thread-outbox", async () => { | |
| 98 | const { Atom } = await import("effect/reactivity"); | |
| 99 | return { dispatchingQueuedMessageIdAtom: Atom.make(null), useThreadOutboxMessages: () => ({}) }; | |
| 100 | }); | |
| 101 | vi.mock("./thread-outbox", () => ({ enqueueThreadOutboxMessage: state.enqueue })); | |
| 102 | vi.mock("./composer-attachment-uploads", async () => { | |
| 103 | const { Atom } = await import("effect/reactivity"); | |
| 104 | return { | |
| 105 | composerAttachmentUploadsAtom: Atom.make({}), | |
| 106 | composerAttachmentUploadBlockReason: () => null, | |
| 107 | composerAttachmentsStillUploading: () => false, | |
| 108 | }; | |
| 109 | }); | |
| 110 | vi.mock("./use-composer-drafts", async () => { | |
| 111 | const { Atom } = await import("effect/reactivity"); | |
| 112 | return { | |
| 113 | composerDraftsAtom: Atom.make({}), | |
| 114 | composerContextImportsAtom: Atom.make({}), | |
| 115 | getComposerDraftSnapshot: () => state.draft, | |
| 116 | clearComposerDraftContent: state.clearDraft, | |
| 117 | clearComposerDraft: state.clearDraft, | |
| 118 | setComposerDraftText: (_key: string, text: string) => { | |
| 119 | state.draft.text = text; | |
| 120 | }, | |
| 121 | updateComposerDraftSettings: state.updateSettings, | |
| 122 | ensureComposerDraftsLoaded: () => {}, | |
| 123 | scheduleUnusedComposerAttachmentCleanup: () => {}, | |
| 124 | appendComposerDraftAttachments: vi.fn(), | |
| 125 | appendComposerDraftText: vi.fn(), | |
| 126 | mergeComposerDraftContent: vi.fn(), | |
| 127 | removeComposerDraftAttachment: vi.fn(), | |
| 128 | useComposerDraft: () => state.draft, | |
| 129 | }; | |
| 130 | }); | |
| 131 | vi.mock("../lib/composerImages", () => ({ | |
| 132 | convertPastedImagesToAttachments: vi.fn(), | |
| 133 | pasteComposerClipboard: vi.fn(), | |
| 134 | pickComposerFiles: vi.fn(), | |
| 135 | pickComposerMedia: vi.fn(), | |
| 136 | })); | |
| 137 | vi.mock("../lib/commandMetadata", () => ({ | |
| 138 | makeQueuedMessageMetadata: () => ({ | |
| 139 | messageId: "message", | |
| 140 | commandId: "command", | |
| 141 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 142 | }), | |
| 143 | })); | |
| 144 | vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); | |
| 145 | vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); | |
| 146 | vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() })); | |
| 147 | ||
| 148 | import { useThreadComposerState } from "./use-thread-composer-state"; | |
| 149 | import { useSelectedThreadRequests } from "./use-selected-thread-requests"; | |
| 150 | ||
| 151 | beforeEach(() => { | |
| 152 | state.grantedEnvironments = new Set(["primary"]); | |
| 153 | state.connectionState = "connected"; | |
| 154 | state.draft = { text: "Keep my draft", attachments: [] }; | |
| 155 | state.pendingRequests = { | |
| 156 | approvals: [ | |
| 157 | { | |
| 158 | requestId: RuntimeRequestId.make("approval"), | |
| 159 | requestKind: "command", | |
| 160 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 161 | responseCapability: "live", | |
| 162 | }, | |
| 163 | ], | |
| 164 | userInputs: [], | |
| 165 | }; | |
| 166 | vi.clearAllMocks(); | |
| 167 | state.approve.mockResolvedValue(AsyncResult.success(undefined)); | |
| 168 | state.answer.mockResolvedValue(AsyncResult.success(undefined)); | |
| 169 | state.feedback.mockResolvedValue(AsyncResult.success({ feedbackId: "feedback" })); | |
| 170 | }); | |
| 171 | ||
| 172 | describe("mobile task permissions", () => { | |
| 173 | it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => { | |
| 174 | const composer = useThreadComposerState(); | |
| 175 | expect(await composer.onSendMessage()).toBeNull(); | |
| 176 | expect(state.enqueue).not.toHaveBeenCalled(); | |
| 177 | expect(state.clearDraft).not.toHaveBeenCalled(); | |
| 178 | composer.onChangeDraftMessage("Edited locally"); | |
| 179 | composer.onUpdateInteractionMode("plan"); | |
| 180 | expect(state.draft.text).toBe("Edited locally"); | |
| 181 | expect(state.updateSettings).toHaveBeenCalledWith("secondary:thread", { | |
| 182 | interactionMode: "plan", | |
| 183 | }); | |
| 184 | }); | |
| 185 | ||
| 186 | it("rechecks a retained send callback before clearing or queueing the draft", async () => { | |
| 187 | state.grantedEnvironments.add("secondary"); | |
| 188 | const composer = useThreadComposerState(); | |
| 189 | state.grantedEnvironments.delete("secondary"); | |
| 190 | expect(await composer.onSendMessage()).toBeNull(); | |
| 191 | expect(state.clearDraft).not.toHaveBeenCalled(); | |
| 192 | expect(state.enqueue).not.toHaveBeenCalled(); | |
| 193 | state.grantedEnvironments.add("secondary"); | |
| 194 | expect(await composer.onSendMessage()).toBe("message"); | |
| 195 | expect(state.enqueue).toHaveBeenCalledWith( | |
| 196 | expect.objectContaining({ | |
| 197 | environmentId: "secondary", | |
| 198 | threadId: "thread", | |
| 199 | text: "Keep my draft", | |
| 200 | }), | |
| 201 | ); | |
| 202 | }); | |
| 203 | ||
| 204 | it("preserves offline queueing without dispatching feedback through the queue action", async () => { | |
| 205 | state.connectionState = "disconnected"; | |
| 206 | expect(await useThreadComposerState().onSendMessage()).toBe("message"); | |
| 207 | expect(state.enqueue).toHaveBeenCalledTimes(1); | |
| 208 | state.draft.text = "/feedback The agent stopped early."; | |
| 209 | expect(await useThreadComposerState().onSendMessage()).toBeNull(); | |
| 210 | expect(state.feedback).not.toHaveBeenCalled(); | |
| 211 | expect(state.enqueue).toHaveBeenCalledTimes(1); | |
| 212 | expect(state.clearDraft).toHaveBeenCalledTimes(1); | |
| 213 | }); | |
| 214 | ||
| 215 | it("rechecks the target environment before an approval response", async () => { | |
| 216 | state.grantedEnvironments.add("secondary"); | |
| 217 | const requests = useSelectedThreadRequests(); | |
| 218 | state.grantedEnvironments.delete("secondary"); | |
| 219 | await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept"); | |
| 220 | expect(state.approve).not.toHaveBeenCalled(); | |
| 221 | state.grantedEnvironments.add("secondary"); | |
| 222 | await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept"); | |
| 223 | expect(state.approve).toHaveBeenCalledWith({ | |
| 224 | environmentId: "secondary", | |
| 225 | input: { threadId: "thread", requestId: "approval", decision: "accept" }, | |
| 226 | }); | |
| 227 | }); | |
| 228 | ||
| 229 | it("keeps answer drafts editable after revocation while blocking their submission", async () => { | |
| 230 | state.pendingRequests = { | |
| 231 | approvals: [], | |
| 232 | userInputs: [ | |
| 233 | { | |
| 234 | requestId: RuntimeRequestId.make("input"), | |
| 235 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 236 | responseCapability: "live", | |
| 237 | dismissible: false, | |
| 238 | questions: [state.question], | |
| 239 | }, | |
| 240 | ], | |
| 241 | }; | |
| 242 | const requestId = RuntimeRequestId.make("input"); | |
| 243 | useSelectedThreadRequests().onSelectUserInputOption(requestId, state.question, "TypeScript"); | |
| 244 | state.grantedEnvironments.add("secondary"); | |
| 245 | const requests = useSelectedThreadRequests(); | |
| 246 | expect(requests.activePendingUserInputAnswers).toEqual({ language: "TypeScript" }); | |
| 247 | state.grantedEnvironments.delete("secondary"); | |
| 248 | await requests.onSubmitUserInput(); | |
| 249 | expect(state.answer).not.toHaveBeenCalled(); | |
| 250 | expect(useSelectedThreadRequests().activePendingUserInputAnswers).toEqual({ | |
| 251 | language: "TypeScript", | |
| 252 | }); | |
| 253 | state.grantedEnvironments.add("secondary"); | |
| 254 | await requests.onSubmitUserInput(); | |
| 255 | expect(state.answer).toHaveBeenCalledWith({ | |
| 256 | environmentId: "secondary", | |
| 257 | input: { threadId: "thread", requestId: "input", answers: { language: "TypeScript" } }, | |
| 258 | }); | |
| 259 | }); | |
| 260 | }); |
apps/mobile/src/state/use-selected-thread-requests.test.tsx+1| ... | ... | @@ -47,6 +47,7 @@ vi.mock("./entities", () => ({ |
| 47 | 47 | ], |
| 48 | 48 | ]), |
| 49 | 49 | })); |
| 50 | vi.mock("./session", () => ({ readEnvironmentScope: () => true })); | |
| 50 | 51 | vi.mock("./threads", () => ({ threadEnvironment: {} })); |
| 51 | 52 | vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); |
| 52 | 53 | vi.mock("./use-thread-selection", () => ({ |
apps/mobile/src/state/use-selected-thread-requests.ts+13-3| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | type PendingThreadRequests, |
| 20 | 20 | type ThreadUserInputQuestion, |
| 21 | 21 | } from "@t3tools/client-runtime/state/thread-requests"; |
| 22 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 22 | 23 | import { Atom } from "effect/reactivity"; |
| 23 | 24 | |
| 24 | 25 | import { threadEnvironment } from "../state/threads"; |
| ... | ... | @@ -33,6 +34,7 @@ import { appAtomRegistry } from "./atom-registry"; |
| 33 | 34 | import { useSelectedThreadPendingRequests } from "./use-thread-detail"; |
| 34 | 35 | import { useThreadSelection } from "./use-thread-selection"; |
| 35 | 36 | import { useAtomCommand } from "./use-atom-command"; |
| 37 | import { readEnvironmentScope } from "./session"; | |
| 36 | 38 | |
| 37 | 39 | const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] }; |
| 38 | 40 | |
| ... | ... | @@ -211,7 +213,10 @@ export function useSelectedThreadRequests() { |
| 211 | 213 | |
| 212 | 214 | const onRespondToApproval = useCallback( |
| 213 | 215 | async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { |
| 214 | if (!selectedThreadShell) { | |
| 216 | if ( | |
| 217 | !selectedThreadShell || | |
| 218 | !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) | |
| 219 | ) { | |
| 215 | 220 | return; |
| 216 | 221 | } |
| 217 | 222 | if ( |
| ... | ... | @@ -241,7 +246,8 @@ export function useSelectedThreadRequests() { |
| 241 | 246 | !selectedThreadShell || |
| 242 | 247 | !activePendingUserInput || |
| 243 | 248 | activePendingUserInput.responseCapability === "not_resumable" || |
| 244 | !activePendingUserInputAnswers | |
| 249 | !activePendingUserInputAnswers || | |
| 250 | !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) | |
| 245 | 251 | ) { |
| 246 | 252 | return; |
| 247 | 253 | } |
| ... | ... | @@ -318,7 +324,11 @@ export function useSelectedThreadRequests() { |
| 318 | 324 | |
| 319 | 325 | // Closes an async question without messaging the agent. |
| 320 | 326 | const onDismissUserInput = useCallback(async () => { |
| 321 | if (!selectedThreadShell || !activePendingUserInput) { | |
| 327 | if ( | |
| 328 | !selectedThreadShell || | |
| 329 | !activePendingUserInput || | |
| 330 | !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) | |
| 331 | ) { | |
| 322 | 332 | return; |
| 323 | 333 | } |
| 324 | 334 |
apps/mobile/src/state/use-thread-composer-state.ts+10| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope } from "./session"; | |
| 1 | 3 | import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; |
| 2 | 4 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 5 | import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; |
| ... | ... | @@ -548,6 +550,12 @@ export function useThreadComposerState() { |
| 548 | 550 | |
| 549 | 551 | const onSendMessage = useCallback( |
| 550 | 552 | async (followUpOverride?: ActiveTurnComposerAction) => { |
| 553 | if ( | |
| 554 | selectedThreadShell && | |
| 555 | selectedEnvironmentRuntime?.connectionState === "connected" && | |
| 556 | !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) | |
| 557 | ) | |
| 558 | return null; | |
| 551 | 559 | if (!selectedThreadShell) { |
| 552 | 560 | return null; |
| 553 | 561 | } |
| ... | ... | @@ -624,6 +632,8 @@ export function useThreadComposerState() { |
| 624 | 632 | ? parseCodexFeedbackCommand(text) |
| 625 | 633 | : null; |
| 626 | 634 | if (feedbackCommand) { |
| 635 | if (!readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)) | |
| 636 | return null; | |
| 627 | 637 | if (thread.activeProviderThreadId === null) { |
| 628 | 638 | Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); |
| 629 | 639 | return null; |
apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | CommandId, | |
| 4 | EnvironmentId, | |
| 5 | MessageId, | |
| 6 | ProjectId, | |
| 7 | ProviderInstanceId, | |
| 8 | ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Cause from "effect/Cause"; | |
| 11 | import { AsyncResult, type Atom } from "effect/reactivity"; | |
| 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 13 | import type { QueuedThreadMessage } from "./thread-outbox-model"; | |
| 14 | ||
| 15 | const state = vi.hoisted(() => ({ | |
| 16 | grantedEnvironments: new Set<string>(), | |
| 17 | draftText: "Keep this draft separate", | |
| 18 | cleanups: [] as Array<() => void>, | |
| 19 | start: vi.fn(), | |
| 20 | metadata: vi.fn(), | |
| 21 | runtime: vi.fn(), | |
| 22 | interaction: vi.fn(), | |
| 23 | prepare: vi.fn(), | |
| 24 | manager: null as unknown as ReturnType< | |
| 25 | typeof import("./thread-outbox-manager").createThreadOutboxManager | |
| 26 | >, | |
| 27 | thread: { | |
| 28 | environmentId: "secondary", | |
| 29 | id: "thread", | |
| 30 | modelSelection: { instanceId: "codex", model: "gpt-5.4" }, | |
| 31 | runtimeMode: "full-access", | |
| 32 | interactionMode: "default", | |
| 33 | session: null, | |
| 34 | }, | |
| 35 | config: { | |
| 36 | providers: [{ instanceId: "codex", driver: "codex" }], | |
| 37 | environment: { capabilities: {} }, | |
| 38 | }, | |
| 39 | })); | |
| 40 | vi.mock("react", () => ({ | |
| 41 | useCallback: <A>(callback: A) => callback, | |
| 42 | useEffect: (effect: () => void | (() => void)) => { | |
| 43 | const cleanup = effect(); | |
| 44 | if (cleanup) state.cleanups.push(cleanup); | |
| 45 | }, | |
| 46 | useRef: <A>(value: A) => ({ current: value }), | |
| 47 | useState: <A>(initial: A) => [initial, vi.fn()], | |
| 48 | })); | |
| 49 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | |
| 50 | vi.mock("@effect/atom-react", async () => { | |
| 51 | const { appAtomRegistry } = await import("./atom-registry"); | |
| 52 | return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) }; | |
| 53 | }); | |
| 54 | vi.mock("./session", () => ({ | |
| 55 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 56 | scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), | |
| 57 | useEnvironmentsWithScope: () => state.grantedEnvironments, | |
| 58 | })); | |
| 59 | vi.mock("./entities", () => ({ | |
| 60 | useProjects: () => [], | |
| 61 | useThreadShells: () => [state.thread], | |
| 62 | useServerConfigs: () => new Map([["secondary", state.config]]), | |
| 63 | })); | |
| 64 | vi.mock("./server", async () => { | |
| 65 | const { Atom } = await import("effect/reactivity"); | |
| 66 | return { | |
| 67 | serverEnvironment: { configValueAtom: Atom.family((_id: string) => Atom.make(state.config)) }, | |
| 68 | }; | |
| 69 | }); | |
| 70 | vi.mock("./threads", async () => { | |
| 71 | const { Atom } = await import("effect/reactivity"); | |
| 72 | return { | |
| 73 | threadEnvironment: { | |
| 74 | startTurn: state.start, | |
| 75 | updateMetadata: state.metadata, | |
| 76 | setRuntimeMode: state.runtime, | |
| 77 | setInteractionMode: state.interaction, | |
| 78 | }, | |
| 79 | environmentThreadShells: { threadShellsAtom: Atom.make([state.thread]) }, | |
| 80 | }; | |
| 81 | }); | |
| 82 | vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command })); | |
| 83 | vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); | |
| 84 | vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "abcd" })); | |
| 85 | vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: state.prepare })); | |
| 86 | vi.mock("./use-remote-environment-registry", () => ({ | |
| 87 | setPendingConnectionError: vi.fn(), | |
| 88 | useRemoteConnectionStatus: () => ({ | |
| 89 | connectedEnvironments: [{ environmentId: "secondary", connectionState: "connected" }], | |
| 90 | }), | |
| 91 | })); | |
| 92 | vi.mock("./thread-outbox", async () => { | |
| 93 | const { createThreadOutboxManager } = await import("./thread-outbox-manager"); | |
| 94 | const { appAtomRegistry } = await import("./atom-registry"); | |
| 95 | state.manager = createThreadOutboxManager({ | |
| 96 | registry: appAtomRegistry, | |
| 97 | storage: { | |
| 98 | load: async () => ({ messages: [], errors: [] }), | |
| 99 | write: async () => {}, | |
| 100 | remove: async () => {}, | |
| 101 | }, | |
| 102 | }); | |
| 103 | return { | |
| 104 | threadOutboxManager: state.manager, | |
| 105 | threadOutboxRevision: (id: MessageId) => state.manager.revisionOf(id), | |
| 106 | confirmThreadOutboxMessageQueued: (message: QueuedThreadMessage) => | |
| 107 | state.manager.confirmQueued(message), | |
| 108 | updateThreadOutboxMessage: (message: QueuedThreadMessage, revision?: number) => | |
| 109 | state.manager.update(message, revision), | |
| 110 | }; | |
| 111 | }); | |
| 112 | vi.mock("./thread-outbox-removal", () => ({ | |
| 113 | removeThreadOutboxMessage: (message: QueuedThreadMessage, revision?: number) => | |
| 114 | state.manager.remove(message, revision), | |
| 115 | })); | |
| 116 | vi.mock("./use-thread-outbox", async () => { | |
| 117 | const { Atom } = await import("effect/reactivity"); | |
| 118 | const { appAtomRegistry } = await import("./atom-registry"); | |
| 119 | await import("./thread-outbox"); | |
| 120 | return { | |
| 121 | editingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), | |
| 122 | dispatchingQueuedMessageIdAtom: Atom.make<MessageId | null>(null).pipe(Atom.keepAlive), | |
| 123 | useThreadOutboxMessages: () => appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom), | |
| 124 | useThreadOutboxShellStatuses: () => new Map([["secondary", "live"]]), | |
| 125 | }; | |
| 126 | }); | |
| 127 | vi.mock("./use-composer-drafts", async () => { | |
| 128 | const { Atom } = await import("effect/reactivity"); | |
| 129 | return { | |
| 130 | composerDraftsAtom: Atom.make({}), | |
| 131 | removeDeliveredCloudQueuedMessage: async () => {}, | |
| 132 | appendComposerDraftAttachments: vi.fn(), | |
| 133 | flushComposerDrafts: vi.fn(), | |
| 134 | getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })), | |
| 135 | mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { | |
| 136 | state.draftText += `\n${input.text}`; | |
| 137 | }), | |
| 138 | replaceComposerDraftAttachments: vi.fn(), | |
| 139 | undoComposerDraftMerge: vi.fn(), | |
| 140 | updateComposerDraftSettings: vi.fn(), | |
| 141 | waitForComposerDraftsLoaded: vi.fn(), | |
| 142 | }; | |
| 143 | }); | |
| 144 | ||
| 145 | import { appAtomRegistry } from "./atom-registry"; | |
| 146 | import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox"; | |
| 147 | import { useThreadOutboxDrain } from "./use-thread-outbox-drain"; | |
| 148 | ||
| 149 | const message = (overrides: Partial<QueuedThreadMessage> = {}): QueuedThreadMessage => ({ | |
| 150 | environmentId: EnvironmentId.make("secondary"), | |
| 151 | threadId: ThreadId.make("thread"), | |
| 152 | commandId: CommandId.make("command"), | |
| 153 | messageId: MessageId.make("message"), | |
| 154 | text: "Keep this queued", | |
| 155 | attachments: [], | |
| 156 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 157 | ...overrides, | |
| 158 | }); | |
| 159 | const remaining = () => | |
| 160 | Object.values(appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom)).flat(); | |
| 161 | ||
| 162 | function runDrain() { | |
| 163 | const settled = Promise.withResolvers<void>(); | |
| 164 | const release = appAtomRegistry.subscribe(dispatchingQueuedMessageIdAtom, (id) => { | |
| 165 | if (id === null) settled.resolve(); | |
| 166 | }); | |
| 167 | useThreadOutboxDrain(); | |
| 168 | return settled.promise.finally(release); | |
| 169 | } | |
| 170 | ||
| 171 | beforeEach(() => { | |
| 172 | state.grantedEnvironments = new Set(["primary"]); | |
| 173 | state.draftText = "Keep this draft separate"; | |
| 174 | appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); | |
| 175 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); | |
| 176 | for (const command of [state.start, state.metadata, state.runtime, state.interaction]) { | |
| 177 | command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 178 | } | |
| 179 | state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ | |
| 180 | status: "ready", | |
| 181 | attachments: [], | |
| 182 | draftAttachments: attachments, | |
| 183 | pendingAttachmentIds: [], | |
| 184 | })); | |
| 185 | }); | |
| 186 | afterEach(() => { | |
| 187 | state.cleanups.splice(0).forEach((cleanup) => cleanup()); | |
| 188 | }); | |
| 189 | ||
| 190 | describe("queued task operation access", () => { | |
| 191 | it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => { | |
| 192 | const queued = message(); | |
| 193 | await state.manager.enqueue(queued); | |
| 194 | useThreadOutboxDrain(); | |
| 195 | expect(state.start).not.toHaveBeenCalled(); | |
| 196 | expect(state.prepare).not.toHaveBeenCalled(); | |
| 197 | expect(remaining()).toEqual([queued]); | |
| 198 | state.grantedEnvironments.add("secondary"); | |
| 199 | await runDrain(); | |
| 200 | expect(state.start).toHaveBeenCalledOnce(); | |
| 201 | expect(remaining()).toEqual([]); | |
| 202 | }); | |
| 203 | ||
| 204 | it("stops the remaining settings and turn commands when an earlier update loses access", async () => { | |
| 205 | state.grantedEnvironments.add("secondary"); | |
| 206 | const queued = message({ | |
| 207 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "different-model" }, | |
| 208 | runtimeMode: "approval-required", | |
| 209 | interactionMode: "plan", | |
| 210 | }); | |
| 211 | await state.manager.enqueue(queued); | |
| 212 | state.runtime.mockImplementationOnce(async () => { | |
| 213 | state.grantedEnvironments.delete("secondary"); | |
| 214 | return AsyncResult.success(undefined); | |
| 215 | }); | |
| 216 | await runDrain(); | |
| 217 | expect(state.runtime).toHaveBeenCalledOnce(); | |
| 218 | expect(state.interaction).not.toHaveBeenCalled(); | |
| 219 | expect(state.prepare).not.toHaveBeenCalled(); | |
| 220 | expect(state.start).not.toHaveBeenCalled(); | |
| 221 | expect(remaining()).toEqual([queued]); | |
| 222 | }); | |
| 223 | ||
| 224 | it.each([ | |
| 225 | { isCreation: false, accepted: false }, | |
| 226 | { isCreation: true, accepted: false }, | |
| 227 | { isCreation: false, accepted: true }, | |
| 228 | { isCreation: true, accepted: true }, | |
| 229 | ])( | |
| 230 | "preserves a rejected queue but removes an accepted turn after access loss (new task: $isCreation, accepted: $accepted)", | |
| 231 | async ({ isCreation, accepted }) => { | |
| 232 | state.grantedEnvironments.add("secondary"); | |
| 233 | const queued = message( | |
| 234 | isCreation | |
| 235 | ? { | |
| 236 | threadId: ThreadId.make("new-thread"), | |
| 237 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 238 | creation: { | |
| 239 | projectId: ProjectId.make("project"), | |
| 240 | projectCwd: "/repo", | |
| 241 | workspaceMode: "local", | |
| 242 | branch: null, | |
| 243 | worktreePath: null, | |
| 244 | }, | |
| 245 | } | |
| 246 | : {}, | |
| 247 | ); | |
| 248 | await state.manager.enqueue(queued); | |
| 249 | const started = Promise.withResolvers<void>(); | |
| 250 | const delivery = Promise.withResolvers<AsyncResult.AsyncResult<void, Error>>(); | |
| 251 | state.start.mockImplementationOnce(() => { | |
| 252 | started.resolve(); | |
| 253 | return delivery.promise; | |
| 254 | }); | |
| 255 | const drained = runDrain(); | |
| 256 | await started.promise; | |
| 257 | state.grantedEnvironments.delete("secondary"); | |
| 258 | delivery.resolve( | |
| 259 | accepted | |
| 260 | ? AsyncResult.success(undefined) | |
| 261 | : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))), | |
| 262 | ); | |
| 263 | await drained; | |
| 264 | ||
| 265 | expect(remaining()).toEqual(accepted ? [] : [queued]); | |
| 266 | expect(state.draftText).toBe("Keep this draft separate"); | |
| 267 | if (!accepted) { | |
| 268 | state.grantedEnvironments.add("secondary"); | |
| 269 | await runDrain(); | |
| 270 | expect(state.start).toHaveBeenCalledTimes(2); | |
| 271 | expect(remaining()).toEqual([]); | |
| 272 | expect(state.draftText).toBe("Keep this draft separate"); | |
| 273 | } | |
| 274 | }, | |
| 275 | ); | |
| 276 | ||
| 277 | it.each([ | |
| 278 | { isCreation: false, uploadFails: false }, | |
| 279 | { isCreation: true, uploadFails: false }, | |
| 280 | { isCreation: false, uploadFails: true }, | |
| 281 | { isCreation: true, uploadFails: true }, | |
| 282 | ])( | |
| 283 | "keeps the message queued after upload revocation (new task: $isCreation, upload fails: $uploadFails)", | |
| 284 | async ({ isCreation, uploadFails }) => { | |
| 285 | state.grantedEnvironments.add("secondary"); | |
| 286 | const queued = message( | |
| 287 | isCreation | |
| 288 | ? { | |
| 289 | threadId: ThreadId.make("new-thread"), | |
| 290 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 291 | creation: { | |
| 292 | projectId: ProjectId.make("project"), | |
| 293 | projectCwd: "/repo", | |
| 294 | workspaceMode: "local", | |
| 295 | branch: null, | |
| 296 | worktreePath: null, | |
| 297 | }, | |
| 298 | } | |
| 299 | : {}, | |
| 300 | ); | |
| 301 | await state.manager.enqueue(queued); | |
| 302 | state.prepare.mockImplementationOnce(async ({ attachments }) => { | |
| 303 | state.grantedEnvironments.delete("secondary"); | |
| 304 | if (uploadFails) throw new Error("This connection cannot upload attachments."); | |
| 305 | return { | |
| 306 | status: "ready", | |
| 307 | attachments: [], | |
| 308 | draftAttachments: attachments, | |
| 309 | pendingAttachmentIds: [], | |
| 310 | }; | |
| 311 | }); | |
| 312 | await runDrain(); | |
| 313 | expect(state.prepare).toHaveBeenCalledOnce(); | |
| 314 | expect(state.start).not.toHaveBeenCalled(); | |
| 315 | expect(remaining()).toEqual([queued]); | |
| 316 | }, | |
| 317 | ); | |
| 318 | }); |
apps/mobile/src/state/use-thread-outbox-drain.test.ts+28| ... | ... | @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" |
| 13 | 13 | import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; |
| 14 | 14 | |
| 15 | 15 | const harness = vi.hoisted(() => ({ |
| 16 | canOperate: true, | |
| 16 | 17 | manager: null as unknown as ReturnType< |
| 17 | 18 | typeof import("./thread-outbox-manager").createThreadOutboxManager |
| 18 | 19 | >, |
| ... | ... | @@ -95,6 +96,11 @@ vi.mock("./use-atom-command", () => ({ |
| 95 | 96 | useAtomCommand: () => async () => undefined, |
| 96 | 97 | })); |
| 97 | 98 | |
| 99 | vi.mock("./session", () => ({ | |
| 100 | readEnvironmentScope: () => harness.canOperate, | |
| 101 | useEnvironmentsWithScope: () => new Set(), | |
| 102 | })); | |
| 103 | ||
| 98 | 104 | vi.mock("./use-thread-outbox", async () => { |
| 99 | 105 | const { Atom } = await import("effect/reactivity"); |
| 100 | 106 | return { |
| ... | ... | @@ -203,6 +209,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> { |
| 203 | 209 | |
| 204 | 210 | beforeEach(() => { |
| 205 | 211 | appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); |
| 212 | harness.canOperate = true; | |
| 206 | 213 | harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); |
| 207 | 214 | }); |
| 208 | 215 | |
| ... | ... | @@ -220,6 +227,27 @@ afterEach(() => { |
| 220 | 227 | }); |
| 221 | 228 | |
| 222 | 229 | describe("thread outbox attachment preparation", () => { |
| 230 | it("keeps a denied message queued without starting its attachment upload", async () => { | |
| 231 | const message = queuedMessage({ | |
| 232 | messageId: "denied-queued-message", | |
| 233 | text: "Keep this queued", | |
| 234 | fileUri: "file:///documents/keep.pdf", | |
| 235 | }); | |
| 236 | await harness.manager.enqueue(message); | |
| 237 | harness.prepareTurnAttachments.mockResolvedValueOnce({ | |
| 238 | status: "ready", | |
| 239 | attachments: [], | |
| 240 | draftAttachments: message.attachments, | |
| 241 | pendingAttachmentIds: [], | |
| 242 | }); | |
| 243 | harness.canOperate = false; | |
| 244 | await expect(prepareQueuedMessageAttachments(message)).resolves.toEqual({ | |
| 245 | status: "abandoned", | |
| 246 | }); | |
| 247 | expect(harness.prepareTurnAttachments).not.toHaveBeenCalled(); | |
| 248 | expect(remainingMessages()).toEqual([message]); | |
| 249 | }); | |
| 250 | ||
| 223 | 251 | it("abandons reused uploads when an editor saves changed text during verification", async () => { |
| 224 | 252 | const message = withReusedFileUpload( |
| 225 | 253 | queuedMessage({ |
apps/mobile/src/state/use-thread-outbox-drain.ts+28| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 8 | 8 | import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; |
| 9 | 9 | import { |
| 10 | AuthOrchestrationOperateScope, | |
| 10 | 11 | CommandId, |
| 11 | 12 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 12 | 13 | DEFAULT_RUNTIME_MODE, |
| ... | ... | @@ -39,6 +40,7 @@ import { |
| 39 | 40 | recordPendingThreadCreationOutcome, |
| 40 | 41 | } from "./pending-thread-creation"; |
| 41 | 42 | import { serverEnvironment } from "./server"; |
| 43 | import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; | |
| 42 | 44 | import { |
| 43 | 45 | confirmThreadOutboxMessageQueued, |
| 44 | 46 | threadOutboxManager, |
| ... | ... | @@ -226,6 +228,9 @@ export async function prepareQueuedMessageAttachments( |
| 226 | 228 | if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) { |
| 227 | 229 | return { status: "abandoned" }; |
| 228 | 230 | } |
| 231 | if (!readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope)) { | |
| 232 | return { status: "abandoned" }; | |
| 233 | } | |
| 229 | 234 | const revision = threadOutboxRevision(queuedMessage.messageId); |
| 230 | 235 | if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) { |
| 231 | 236 | return { status: "abandoned" }; |
| ... | ... | @@ -668,6 +673,10 @@ export function useThreadOutboxDrain(): void { |
| 668 | 673 | const projects = useProjects(); |
| 669 | 674 | const serverConfigs = useServerConfigs(); |
| 670 | 675 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 676 | const operableEnvironments = useEnvironmentsWithScope( | |
| 677 | connectedEnvironments, | |
| 678 | AuthOrchestrationOperateScope, | |
| 679 | ); | |
| 671 | 680 | const [retryTick, setRetryTick] = useState(0); |
| 672 | 681 | const retryAttemptRef = useRef(new Map<MessageId, number>()); |
| 673 | 682 | const retryNotBeforeRef = useRef(new Map<MessageId, number>()); |
| ... | ... | @@ -794,6 +803,9 @@ export function useThreadOutboxDrain(): void { |
| 794 | 803 | |
| 795 | 804 | const sendQueuedMessage = useCallback( |
| 796 | 805 | async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { |
| 806 | const hasAccess = () => | |
| 807 | readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); | |
| 808 | if (!hasAccess()) return true; | |
| 797 | 809 | const serverConfig = appAtomRegistry.get( |
| 798 | 810 | serverEnvironment.configValueAtom(queuedMessage.environmentId), |
| 799 | 811 | ); |
| ... | ... | @@ -808,6 +820,7 @@ export function useThreadOutboxDrain(): void { |
| 808 | 820 | const { reportFailure } = makeDeliveryHelpers(queuedMessage); |
| 809 | 821 | |
| 810 | 822 | if (settings.runtimeMode !== thread.runtimeMode) { |
| 823 | if (!hasAccess()) return true; | |
| 811 | 824 | const runtimeResult = await setThreadRuntimeMode({ |
| 812 | 825 | environmentId: queuedMessage.environmentId, |
| 813 | 826 | input: { |
| ... | ... | @@ -824,6 +837,7 @@ export function useThreadOutboxDrain(): void { |
| 824 | 837 | } |
| 825 | 838 | |
| 826 | 839 | if (settings.interactionMode !== thread.interactionMode) { |
| 840 | if (!hasAccess()) return true; | |
| 827 | 841 | const interactionResult = await setThreadInteractionMode({ |
| 828 | 842 | environmentId: queuedMessage.environmentId, |
| 829 | 843 | input: { |
| ... | ... | @@ -842,6 +856,7 @@ export function useThreadOutboxDrain(): void { |
| 842 | 856 | let prepared: PreparedTurnAttachments; |
| 843 | 857 | let persistedMessage: QueuedThreadMessage; |
| 844 | 858 | let deliveryRevision: number; |
| 859 | if (!hasAccess()) return true; | |
| 845 | 860 | try { |
| 846 | 861 | const preparedResult = await prepareQueuedMessageAttachments( |
| 847 | 862 | queuedMessage, |
| ... | ... | @@ -861,6 +876,7 @@ export function useThreadOutboxDrain(): void { |
| 861 | 876 | return true; |
| 862 | 877 | } |
| 863 | 878 | } catch (error) { |
| 879 | if (!hasAccess()) return true; | |
| 864 | 880 | logThreadOutboxUploadFailure(queuedMessage, error); |
| 865 | 881 | if (!shouldRetryThreadOutboxDelivery(error)) { |
| 866 | 882 | return restoreQueuedMessage( |
| ... | ... | @@ -888,6 +904,7 @@ export function useThreadOutboxDrain(): void { |
| 888 | 904 | settings, |
| 889 | 905 | currentConfig.providers, |
| 890 | 906 | ); |
| 907 | if (!hasAccess()) return true; | |
| 891 | 908 | const deliveryResult = await startTurn({ |
| 892 | 909 | environmentId: queuedMessage.environmentId, |
| 893 | 910 | input: { |
| ... | ... | @@ -922,6 +939,7 @@ export function useThreadOutboxDrain(): void { |
| 922 | 939 | dispatchMode: queuedMessage.dispatchMode ?? "start", |
| 923 | 940 | }, |
| 924 | 941 | }); |
| 942 | if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; | |
| 925 | 943 | const failure = reportFailure(deliveryResult, "start-turn"); |
| 926 | 944 | if (failure?.action === "retry") { |
| 927 | 945 | return false; |
| ... | ... | @@ -952,6 +970,9 @@ export function useThreadOutboxDrain(): void { |
| 952 | 970 | creation: QueuedThreadCreation, |
| 953 | 971 | projectCwd: string, |
| 954 | 972 | ) => { |
| 973 | const hasAccess = () => | |
| 974 | readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); | |
| 975 | if (!hasAccess()) return true; | |
| 955 | 976 | const modelSelection = queuedMessage.modelSelection; |
| 956 | 977 | if (modelSelection === undefined) { |
| 957 | 978 | return false; |
| ... | ... | @@ -997,6 +1018,7 @@ export function useThreadOutboxDrain(): void { |
| 997 | 1018 | return true; |
| 998 | 1019 | } |
| 999 | 1020 | } catch (error) { |
| 1021 | if (!hasAccess()) return true; | |
| 1000 | 1022 | logThreadOutboxUploadFailure(queuedMessage, error); |
| 1001 | 1023 | if (!shouldRetryThreadOutboxDelivery(error)) { |
| 1002 | 1024 | return restoreQueuedMessage( |
| ... | ... | @@ -1024,6 +1046,7 @@ export function useThreadOutboxDrain(): void { |
| 1024 | 1046 | settings, |
| 1025 | 1047 | currentConfig.providers, |
| 1026 | 1048 | ); |
| 1049 | if (!hasAccess()) return true; | |
| 1027 | 1050 | const deliveryResult = await startTurn({ |
| 1028 | 1051 | environmentId: queuedMessage.environmentId, |
| 1029 | 1052 | input: buildProjectThreadStartTurnInput({ |
| ... | ... | @@ -1054,6 +1077,7 @@ export function useThreadOutboxDrain(): void { |
| 1054 | 1077 | }), |
| 1055 | 1078 | }); |
| 1056 | 1079 | const { reportFailure } = makeDeliveryHelpers(queuedMessage); |
| 1080 | if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; | |
| 1057 | 1081 | const failure = reportFailure(deliveryResult, "start-turn"); |
| 1058 | 1082 | if (failure?.action === "retry") { |
| 1059 | 1083 | return false; |
| ... | ... | @@ -1188,6 +1212,9 @@ export function useThreadOutboxDrain(): void { |
| 1188 | 1212 | environmentConnected: environment?.connectionState === "connected", |
| 1189 | 1213 | threadBusy: threadRuntimeIsActive(thread?.runtime), |
| 1190 | 1214 | }); |
| 1215 | if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) { | |
| 1216 | continue; | |
| 1217 | } | |
| 1191 | 1218 | // The delivery action resolves first; capability checks apply only to |
| 1192 | 1219 | // a message that will send. Checking earlier would restore a |
| 1193 | 1220 | // creation whose startTurn already made the thread as a duplicate draft |
| ... | ... | @@ -1345,6 +1372,7 @@ export function useThreadOutboxDrain(): void { |
| 1345 | 1372 | connectedEnvironments, |
| 1346 | 1373 | dispatchingQueuedMessageId, |
| 1347 | 1374 | editingQueuedMessageIds, |
| 1375 | operableEnvironments, | |
| 1348 | 1376 | projects, |
| 1349 | 1377 | queuedMessagesByThreadKey, |
| 1350 | 1378 | retryTick, |
apps/server/src/auth/EnvironmentAuth.test.ts+3-18| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { AuthAdministrativeScopes } from "@t3tools/contracts"; | |
| 2 | import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; | |
| 3 | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -328,13 +328,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 328 | 328 | ); |
| 329 | 329 | |
| 330 | 330 | expect(verified.sessionId.length).toBeGreaterThan(0); |
| 331 | expect(verified.scopes).toEqual([ | |
| 332 | "orchestration:read", | |
| 333 | "orchestration:operate", | |
| 334 | "terminal:operate", | |
| 335 | "review:write", | |
| 336 | "relay:read", | |
| 337 | ]); | |
| 331 | expect(verified.scopes).toEqual(AuthStandardClientScopes); | |
| 338 | 332 | expect(verified.subject).toBe("one-time-token"); |
| 339 | 333 | }).pipe(Effect.provide(layerEnvironmentAuth())), |
| 340 | 334 | ); |
| ... | ... | @@ -535,16 +529,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 535 | 529 | makeCookieRequest(sessions.cookieName, exchanged.sessionToken), |
| 536 | 530 | ); |
| 537 | 531 | |
| 538 | expect(verified.scopes).toEqual([ | |
| 539 | "orchestration:read", | |
| 540 | "orchestration:operate", | |
| 541 | "terminal:operate", | |
| 542 | "review:write", | |
| 543 | "relay:read", | |
| 544 | "access:read", | |
| 545 | "access:write", | |
| 546 | "relay:write", | |
| 547 | ]); | |
| 532 | expect(verified.scopes).toEqual(AuthAdministrativeScopes); | |
| 548 | 533 | expect(verified.subject).toBe("administrative-bootstrap"); |
| 549 | 534 | }).pipe(Effect.provide(layerEnvironmentAuth())), |
| 550 | 535 | ); |
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+3-20| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { AuthAdministrativeScopes } from "@t3tools/contracts"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -79,29 +80,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = |
| 79 | 80 | const listedAfterRevoke = yield* environmentAuth.listSessions(); |
| 80 | 81 | |
| 81 | 82 | expect(issued.method).toBe("bearer-access-token"); |
| 82 | expect(issued.scopes).toEqual([ | |
| 83 | "orchestration:read", | |
| 84 | "orchestration:operate", | |
| 85 | "terminal:operate", | |
| 86 | "review:write", | |
| 87 | "relay:read", | |
| 88 | "access:read", | |
| 89 | "access:write", | |
| 90 | "relay:write", | |
| 91 | ]); | |
| 83 | expect(issued.scopes).toEqual(AuthAdministrativeScopes); | |
| 92 | 84 | expect(issued.client.deviceType).toBe("bot"); |
| 93 | 85 | expect(issued.client.label).toBe("deploy-bot"); |
| 94 | 86 | expect(verified.sessionId).toBe(issued.sessionId); |
| 95 | expect(verified.scopes).toEqual([ | |
| 96 | "orchestration:read", | |
| 97 | "orchestration:operate", | |
| 98 | "terminal:operate", | |
| 99 | "review:write", | |
| 100 | "relay:read", | |
| 101 | "access:read", | |
| 102 | "access:write", | |
| 103 | "relay:write", | |
| 104 | ]); | |
| 87 | expect(verified.scopes).toEqual(AuthAdministrativeScopes); | |
| 105 | 88 | expect(verified.method).toBe("bearer-access-token"); |
| 106 | 89 | expect(listedBeforeRevoke).toHaveLength(1); |
| 107 | 90 | expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); |
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+2| ... | ... | @@ -34,6 +34,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 34 | 34 | |
| 35 | 35 | expect(descriptor.policy).toBe("desktop-managed-local"); |
| 36 | 36 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); |
| 37 | expect(descriptor.serverUpdateScope).toBe("environment:maintain"); | |
| 37 | 38 | // Packaged desktop has no devUrl, but still needs the port scope: it |
| 38 | 39 | // scans upward from 3773 for a free port and binds 127.0.0.1, so a second |
| 39 | 40 | // instance shares this one's hostname on a different port. |
| ... | ... | @@ -88,6 +89,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 88 | 89 | |
| 89 | 90 | expect(descriptor.policy).toBe("loopback-browser"); |
| 90 | 91 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); |
| 92 | expect(descriptor.serverUpdateScope).toBe("environment:maintain"); | |
| 91 | 93 | expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/); |
| 92 | 94 | }).pipe( |
| 93 | 95 | Effect.provide( |
apps/server/src/auth/EnvironmentAuthPolicy.ts+2-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 1 | import { AuthEnvironmentMaintainScope, type ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -40,6 +40,7 @@ export const make = Effect.gen(function* () { |
| 40 | 40 | policy, |
| 41 | 41 | bootstrapMethods, |
| 42 | 42 | sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], |
| 43 | serverUpdateScope: AuthEnvironmentMaintainScope, | |
| 43 | 44 | sessionCookieName: resolveSessionCookieName({ |
| 44 | 45 | mode: config.mode, |
| 45 | 46 | port: config.port, |
apps/server/src/auth/McpOAuth.test.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { |
| 3 | 3 | AuthAdministrativeScopes, |
| 4 | type AuthEnvironmentScope, | |
| 4 | type AuthCreatePairingCredentialInput, | |
| 5 | 5 | EnvironmentHttpApi, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { expect, it } from "@effect/vitest"; |
| ... | ... | @@ -418,7 +418,7 @@ it.live("one-click approves only access the browser session holds the scopes for |
| 418 | 418 | const clientId = yield* registeredClientId(handler); |
| 419 | 419 | const params = authorizeParams(clientId); |
| 420 | 420 | // Signs a browser in through the real route and returns its session cookie. |
| 421 | const browserCookie = (scopes: ReadonlyArray<AuthEnvironmentScope>) => | |
| 421 | const browserCookie = (scopes: NonNullable<AuthCreatePairingCredentialInput["scopes"]>) => | |
| 422 | 422 | Effect.gen(function* () { |
| 423 | 423 | const pairing = yield* auth.issuePairingCredential({ scopes }); |
| 424 | 424 | const response = yield* handler( |
apps/server/src/auth/PairingGrantStore.test.ts+3-17| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -83,13 +84,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 83 | 84 | const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); |
| 84 | 85 | |
| 85 | 86 | expect(first.method).toBe("one-time-token"); |
| 86 | expect(first.scopes).toEqual([ | |
| 87 | "orchestration:read", | |
| 88 | "orchestration:operate", | |
| 89 | "terminal:operate", | |
| 90 | "review:write", | |
| 91 | "relay:read", | |
| 92 | ]); | |
| 87 | expect(first.scopes).toEqual(AuthStandardClientScopes); | |
| 93 | 88 | expect(first.subject).toBe("one-time-token"); |
| 94 | 89 | expect(first.label).toBe("Julius iPhone"); |
| 95 | 90 | expect(issued.label).toBe("Julius iPhone"); |
| ... | ... | @@ -168,16 +163,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 168 | 163 | const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); |
| 169 | 164 | |
| 170 | 165 | expect(first.method).toBe("desktop-bootstrap"); |
| 171 | expect(first.scopes).toEqual([ | |
| 172 | "orchestration:read", | |
| 173 | "orchestration:operate", | |
| 174 | "terminal:operate", | |
| 175 | "review:write", | |
| 176 | "relay:read", | |
| 177 | "access:read", | |
| 178 | "access:write", | |
| 179 | "relay:write", | |
| 180 | ]); | |
| 166 | expect(first.scopes).toEqual(AuthAdministrativeScopes); | |
| 181 | 167 | expect(first.subject).toBe("desktop-bootstrap"); |
| 182 | 168 | expect(second.method).toBe("desktop-bootstrap"); |
| 183 | 169 | expect(third.method).toBe("desktop-bootstrap"); |
apps/server/src/auth/RpcAuthorization.test.ts+80-6| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | 1 | import { |
| 2 | AuthEnvironmentMaintainScope, | |
| 3 | AuthProvidersManageScope, | |
| 4 | AuthSettingsWriteScope, | |
| 5 | DEFAULT_SERVER_SETTINGS, | |
| 6 | ProviderInstanceId, | |
| 2 | 7 | AuthOrchestrationOperateScope, |
| 3 | 8 | AuthOrchestrationReadScope, |
| 4 | 9 | AuthRelayReadScope, |
| ... | ... | @@ -28,7 +33,7 @@ describe("RPC authorization scopes", () => { |
| 28 | 33 | AuthOrchestrationReadScope, |
| 29 | 34 | ); |
| 30 | 35 | expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( |
| 31 | AuthOrchestrationOperateScope, | |
| 36 | AuthEnvironmentMaintainScope, | |
| 32 | 37 | ); |
| 33 | 38 | expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( |
| 34 | 39 | AuthOrchestrationReadScope, |
| ... | ... | @@ -75,13 +80,13 @@ describe("RPC authorization scopes", () => { |
| 75 | 80 | AuthOrchestrationReadScope, |
| 76 | 81 | ); |
| 77 | 82 | expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe( |
| 78 | AuthOrchestrationOperateScope, | |
| 83 | AuthProvidersManageScope, | |
| 79 | 84 | ); |
| 80 | 85 | expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe( |
| 81 | AuthOrchestrationOperateScope, | |
| 86 | AuthProvidersManageScope, | |
| 82 | 87 | ); |
| 83 | 88 | expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe( |
| 84 | AuthOrchestrationOperateScope, | |
| 89 | AuthProvidersManageScope, | |
| 85 | 90 | ); |
| 86 | 91 | expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe( |
| 87 | 92 | AuthOrchestrationReadScope, |
| ... | ... | @@ -90,7 +95,7 @@ describe("RPC authorization scopes", () => { |
| 90 | 95 | AuthOrchestrationOperateScope, |
| 91 | 96 | ); |
| 92 | 97 | expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe( |
| 93 | AuthOrchestrationOperateScope, | |
| 98 | AuthProvidersManageScope, | |
| 94 | 99 | ); |
| 95 | 100 | }); |
| 96 | 101 | |
| ... | ... | @@ -160,9 +165,78 @@ describe("RPC scope middleware", () => { |
| 160 | 165 | yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), |
| 161 | 166 | ).toMatchObject({ |
| 162 | 167 | _tag: "EnvironmentAuthorizationError", |
| 163 | requiredScope: AuthOrchestrationOperateScope, | |
| 168 | requiredScope: AuthEnvironmentMaintainScope, | |
| 164 | 169 | }); |
| 165 | 170 | expect(handled).toEqual([]); |
| 166 | 171 | }).pipe(Effect.scoped), |
| 167 | 172 | ); |
| 168 | 173 | }); |
| 174 | ||
| 175 | describe("settings mutation authorization", () => { | |
| 176 | const group = WsRpcGroup.omit( | |
| 177 | ...[...WsRpcGroup.requests.keys()].filter( | |
| 178 | ( | |
| 179 | tag, | |
| 180 | ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.serverUpdateSettings> => | |
| 181 | tag !== WS_METHODS.serverUpdateSettings, | |
| 182 | ), | |
| 183 | ); | |
| 184 | const providerInstanceMutation = { | |
| 185 | operation: "remove" as const, | |
| 186 | instanceId: ProviderInstanceId.make("codex_work"), | |
| 187 | }; | |
| 188 | ||
| 189 | it.effect("allows provider-only mutations while denying mixed settings without their grant", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | let handled = 0; | |
| 192 | const client = yield* RpcTest.makeClient(group).pipe( | |
| 193 | Effect.provide( | |
| 194 | Layer.mergeAll( | |
| 195 | group.toLayerHandler(WS_METHODS.serverUpdateSettings, () => | |
| 196 | Effect.sync(() => { | |
| 197 | handled++; | |
| 198 | return DEFAULT_SERVER_SETTINGS; | |
| 199 | }), | |
| 200 | ), | |
| 201 | RpcAuthorization.layer([AuthProvidersManageScope]), | |
| 202 | ), | |
| 203 | ), | |
| 204 | ); | |
| 205 | yield* client[WS_METHODS.serverUpdateSettings]({ patch: {}, providerInstanceMutation }); | |
| 206 | expect(handled).toBe(1); | |
| 207 | expect( | |
| 208 | yield* client[WS_METHODS.serverUpdateSettings]({ | |
| 209 | patch: { defaultRuntimeMode: "full-access" }, | |
| 210 | providerInstanceMutation, | |
| 211 | }).pipe(Effect.flip), | |
| 212 | ).toMatchObject({ requiredScope: AuthSettingsWriteScope }); | |
| 213 | expect(handled).toBe(1); | |
| 214 | }).pipe(Effect.scoped), | |
| 215 | ); | |
| 216 | ||
| 217 | it.effect("does not let a settings grant create or remove providers", () => | |
| 218 | Effect.gen(function* () { | |
| 219 | let handled = false; | |
| 220 | const client = yield* RpcTest.makeClient(group).pipe( | |
| 221 | Effect.provide( | |
| 222 | Layer.mergeAll( | |
| 223 | group.toLayerHandler(WS_METHODS.serverUpdateSettings, () => | |
| 224 | Effect.sync(() => { | |
| 225 | handled = true; | |
| 226 | return DEFAULT_SERVER_SETTINGS; | |
| 227 | }), | |
| 228 | ), | |
| 229 | RpcAuthorization.layer([AuthSettingsWriteScope]), | |
| 230 | ), | |
| 231 | ), | |
| 232 | ); | |
| 233 | expect( | |
| 234 | yield* client[WS_METHODS.serverUpdateSettings]({ | |
| 235 | patch: {}, | |
| 236 | providerInstanceMutation, | |
| 237 | }).pipe(Effect.flip), | |
| 238 | ).toMatchObject({ requiredScope: AuthProvidersManageScope }); | |
| 239 | expect(handled).toBe(false); | |
| 240 | }).pipe(Effect.scoped), | |
| 241 | ); | |
| 242 | }); |
apps/server/src/auth/RpcAuthorization.ts+64-43| ... | ... | @@ -1,6 +1,13 @@ |
| 1 | 1 | import { |
| 2 | CLIENT_GUARDED_RPC_SCOPES, | |
| 2 | 3 | type DeviceListInput, |
| 3 | 4 | AuthAccessReadScope, |
| 5 | ServerSettingsPatch, | |
| 6 | ProviderInstanceMutation, | |
| 7 | requiredScopesForServerSettingsPatch, | |
| 8 | AuthSettingsWriteScope, | |
| 9 | AuthProvidersManageScope, | |
| 10 | AuthEnvironmentMaintainScope, | |
| 4 | 11 | AuthOrchestrationOperateScope, |
| 5 | 12 | AuthOrchestrationReadScope, |
| 6 | 13 | AuthRelayReadScope, |
| ... | ... | @@ -15,6 +22,7 @@ import { |
| 15 | 22 | WsRpcGroup, |
| 16 | 23 | } from "@t3tools/contracts"; |
| 17 | 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Schema from "effect/Schema"; | |
| 18 | 26 | import * as Layer from "effect/Layer"; |
| 19 | 27 | import type * as RpcGroup from "effect/rpc/RpcGroup"; |
| 20 | 28 | |
| ... | ... | @@ -26,6 +34,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"]; |
| 26 | 34 | * runtime failure. |
| 27 | 35 | */ |
| 28 | 36 | export const RPC_REQUIRED_SCOPES = { |
| 37 | ...CLIENT_GUARDED_RPC_SCOPES, | |
| 29 | 38 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, |
| 30 | 39 | [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, |
| 31 | 40 | [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, |
| ... | ... | @@ -41,61 +50,56 @@ export const RPC_REQUIRED_SCOPES = { |
| 41 | 50 | [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, |
| 42 | 51 | [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, |
| 43 | 52 | [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, |
| 44 | [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, | |
| 45 | [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, | |
| 46 | [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, | |
| 47 | [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, | |
| 48 | [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, | |
| 49 | [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope, | |
| 50 | [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope, | |
| 51 | [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope, | |
| 52 | [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope, | |
| 53 | [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, | |
| 54 | [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, | |
| 55 | [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, | |
| 56 | [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, | |
| 57 | [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope, | |
| 58 | [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope, | |
| 53 | [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, | |
| 54 | [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, | |
| 55 | [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, | |
| 56 | [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, | |
| 57 | [WS_METHODS.providerAuthComplete]: AuthProvidersManageScope, | |
| 58 | [WS_METHODS.chatGptReconnectProfile]: AuthProvidersManageScope, | |
| 59 | [WS_METHODS.chatGptImportProfile]: AuthProvidersManageScope, | |
| 60 | [WS_METHODS.chatGptHandoffSubscribe]: AuthProvidersManageScope, | |
| 61 | [WS_METHODS.codexAuthCallbackSubscribe]: AuthProvidersManageScope, | |
| 62 | [WS_METHODS.providerAuthRespond]: AuthProvidersManageScope, | |
| 63 | [WS_METHODS.providerAuthCancel]: AuthProvidersManageScope, | |
| 64 | [WS_METHODS.providerAuthLogout]: AuthProvidersManageScope, | |
| 65 | [WS_METHODS.providerAuthSubscribe]: AuthProvidersManageScope, | |
| 66 | [WS_METHODS.providerInstallStart]: AuthProvidersManageScope, | |
| 67 | [WS_METHODS.providerInstallCancel]: AuthProvidersManageScope, | |
| 59 | 68 | [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope, |
| 60 | [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope, | |
| 61 | [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, | |
| 62 | [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, | |
| 63 | [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope, | |
| 64 | [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, | |
| 65 | [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, | |
| 69 | [WS_METHODS.providerInstallRemove]: AuthProvidersManageScope, | |
| 70 | [WS_METHODS.serverUpdateServer]: AuthEnvironmentMaintainScope, | |
| 71 | [WS_METHODS.serverUpdateServerWithProgress]: AuthEnvironmentMaintainScope, | |
| 72 | [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, | |
| 73 | [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, | |
| 74 | [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, | |
| 66 | 75 | [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, |
| 67 | [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, | |
| 76 | [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, | |
| 68 | 77 | [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, |
| 69 | [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthOrchestrationOperateScope, | |
| 70 | [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthOrchestrationOperateScope, | |
| 71 | [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthOrchestrationOperateScope, | |
| 78 | [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, | |
| 79 | [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope, | |
| 80 | [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthProvidersManageScope, | |
| 72 | 81 | [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope, |
| 73 | 82 | [WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope, |
| 74 | 83 | [WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope, |
| 75 | 84 | [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope, |
| 76 | [WS_METHODS.serverSetAcpRegistryProvider]: AuthOrchestrationOperateScope, | |
| 77 | [WS_METHODS.serverDisableAcpRegistryProvider]: AuthOrchestrationOperateScope, | |
| 78 | [WS_METHODS.serverLogoutAcpRegistry]: AuthOrchestrationOperateScope, | |
| 85 | [WS_METHODS.serverSetAcpRegistryProvider]: AuthProvidersManageScope, | |
| 86 | [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, | |
| 87 | [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, | |
| 79 | 88 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, |
| 80 | 89 | [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, |
| 81 | 90 | [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, |
| 82 | 91 | [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, |
| 83 | 92 | [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, |
| 84 | 93 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, |
| 85 | [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, | |
| 94 | [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, | |
| 86 | 95 | [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, |
| 87 | 96 | [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, |
| 88 | [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, | |
| 97 | [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, | |
| 89 | 98 | [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, |
| 90 | [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, | |
| 99 | [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, | |
| 91 | 100 | [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, |
| 92 | 101 | [WS_METHODS.scheduledTasksList]: AuthOrchestrationReadScope, |
| 93 | 102 | [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope, |
| 94 | [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, | |
| 95 | [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, | |
| 96 | [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, | |
| 97 | [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, | |
| 98 | [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, | |
| 99 | 103 | [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope, |
| 100 | 104 | // Delivery logs hold request bodies, so they need the same scope as the URL. |
| 101 | 105 | [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope, |
| ... | ... | @@ -195,8 +199,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 195 | 199 | [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, |
| 196 | 200 | [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, |
| 197 | 201 | [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, |
| 198 | [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope, | |
| 199 | [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope, | |
| 202 | [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope, | |
| 203 | [WS_METHODS.deviceTestHost]: AuthSettingsWriteScope, | |
| 200 | 204 | [WS_METHODS.deviceList]: AuthOrchestrationReadScope, |
| 201 | 205 | [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, |
| 202 | 206 | [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, |
| ... | ... | @@ -227,13 +231,30 @@ export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => |
| 227 | 231 | requiredScope, |
| 228 | 232 | }); |
| 229 | 233 | |
| 234 | const SettingsUpdate = Schema.Struct({ | |
| 235 | patch: ServerSettingsPatch, | |
| 236 | providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation), | |
| 237 | }); | |
| 238 | ||
| 239 | const requiredScopesForSettingsUpdate = (payload: unknown) => { | |
| 240 | const input = Schema.decodeUnknownSync(SettingsUpdate)(payload); | |
| 241 | const scopes = requiredScopesForServerSettingsPatch(input.patch); | |
| 242 | if (input.providerInstanceMutation === undefined) return scopes; | |
| 243 | // An atomic provider mutation carries an empty patch unless it also changes settings. | |
| 244 | return Object.values(input.patch).every((value) => value === undefined) | |
| 245 | ? [AuthProvidersManageScope] | |
| 246 | : [...new Set([...scopes, AuthProvidersManageScope])]; | |
| 247 | }; | |
| 248 | ||
| 230 | 249 | /** Authorizes every RPC on one connection against that connection's session scopes. */ |
| 231 | 250 | export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) => |
| 232 | Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => { | |
| 233 | const requiredScope = requiredScopeForRpcMethod(rpc._tag); | |
| 234 | return scopes.includes(requiredScope) | |
| 235 | ? effect | |
| 236 | : Effect.fail(rpcAuthorizationError(requiredScope)); | |
| 251 | Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { | |
| 252 | const requiredScopes = | |
| 253 | rpc._tag === WS_METHODS.serverUpdateSettings | |
| 254 | ? requiredScopesForSettingsUpdate(payload) | |
| 255 | : [requiredScopeForRpcMethod(rpc._tag)]; | |
| 256 | const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); | |
| 257 | return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); | |
| 237 | 258 | }); |
| 238 | 259 | |
| 239 | 260 | /** Retrying can install or restart tools even though ordinary listing is readable. */ |
apps/server/src/auth/SessionStore.test.ts+2-8| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as Duration from "effect/Duration"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -346,13 +346,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 346 | 346 | |
| 347 | 347 | expect(verified.method).toBe("bearer-access-token"); |
| 348 | 348 | expect(verified.subject).toBe("test-clock"); |
| 349 | expect(verified.scopes).toEqual([ | |
| 350 | "orchestration:read", | |
| 351 | "orchestration:operate", | |
| 352 | "terminal:operate", | |
| 353 | "review:write", | |
| 354 | "relay:read", | |
| 355 | ]); | |
| 349 | expect(verified.scopes).toEqual(AuthStandardClientScopes); | |
| 356 | 350 | }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), |
| 357 | 351 | ); |
| 358 | 352 |
apps/server/src/auth/http.ts+6| ... | ... | @@ -2,6 +2,9 @@ import { |
| 2 | 2 | AuthAccessReadScope, |
| 3 | 3 | AuthAccessWriteScope, |
| 4 | 4 | AuthStandardClientScopes, |
| 5 | AuthSettingsWriteScope, | |
| 6 | AuthProvidersManageScope, | |
| 7 | AuthEnvironmentMaintainScope, | |
| 5 | 8 | AuthOrchestrationOperateScope, |
| 6 | 9 | AuthOrchestrationReadScope, |
| 7 | 10 | AuthRelayReadScope, |
| ... | ... | @@ -366,6 +369,9 @@ export const layer = HttpApiBuilder.group( |
| 366 | 369 | allowedScopes: new Set<AuthEnvironmentScope>([ |
| 367 | 370 | AuthOrchestrationReadScope, |
| 368 | 371 | AuthOrchestrationOperateScope, |
| 372 | AuthSettingsWriteScope, | |
| 373 | AuthProvidersManageScope, | |
| 374 | AuthEnvironmentMaintainScope, | |
| 369 | 375 | AuthTerminalOperateScope, |
| 370 | 376 | AuthReviewWriteScope, |
| 371 | 377 | AuthAccessReadScope, |
apps/server/src/observability/RpcInstrumentation.test.ts+2-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | |
| 3 | AuthEnvironmentMaintainScope, | |
| 4 | 4 | AuthOrchestrationReadScope, |
| 5 | 5 | type AuthEnvironmentScope, |
| 6 | 6 | ScheduledTaskError, |
| ... | ... | @@ -319,7 +319,7 @@ describe("WS RPC instrumentation middleware", () => { |
| 319 | 319 | message: Option.none(), |
| 320 | 320 | }).pipe(Effect.withSpan("signalProcess.child")), |
| 321 | 321 | ), |
| 322 | connectionMiddleware([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), | |
| 322 | connectionMiddleware([AuthOrchestrationReadScope, AuthEnvironmentMaintainScope]), | |
| 323 | 323 | ), |
| 324 | 324 | ), |
| 325 | 325 | ); |
apps/server/src/provider/ProviderRegistry.test.ts+93-1| ... | ... | @@ -2,7 +2,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 2 | 2 | import * as CodexInstallation from "./CodexInstallation.ts"; |
| 3 | 3 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { describe, it, assert } from "@effect/vitest"; | |
| 5 | import { describe, it, assert, expect } from "@effect/vitest"; | |
| 6 | 6 | import * as DateTime from "effect/DateTime"; |
| 7 | 7 | import * as Deferred from "effect/Deferred"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -2077,6 +2077,98 @@ it.layer( |
| 2077 | 2077 | ]); |
| 2078 | 2078 | }); |
| 2079 | 2079 | |
| 2080 | it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => | |
| 2081 | Effect.gen(function* () { | |
| 2082 | const driver = ProviderDriverKind.make("codex"); | |
| 2083 | const instanceId = ProviderInstanceId.make("codex"); | |
| 2084 | const provider = { | |
| 2085 | instanceId, | |
| 2086 | driver, | |
| 2087 | status: "ready", | |
| 2088 | enabled: true, | |
| 2089 | installed: true, | |
| 2090 | auth: { status: "authenticated" }, | |
| 2091 | checkedAt: "2026-06-10T00:00:00.000Z", | |
| 2092 | version: "1.0.0", | |
| 2093 | models: [], | |
| 2094 | slashCommands: [], | |
| 2095 | skills: [], | |
| 2096 | } as const satisfies ServerProvider; | |
| 2097 | const refreshCalls = yield* Ref.make(0); | |
| 2098 | const probeStarted = yield* Deferred.make<void>(); | |
| 2099 | const releaseProbe = yield* Deferred.make<void>(); | |
| 2100 | const instances = [ | |
| 2101 | { | |
| 2102 | instanceId, | |
| 2103 | driverKind: driver, | |
| 2104 | continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, | |
| 2105 | displayName: undefined, | |
| 2106 | enabled: true, | |
| 2107 | snapshot: { | |
| 2108 | resolveMaintenance: () => | |
| 2109 | Effect.succeed( | |
| 2110 | makeManualOnlyProviderMaintenanceCapabilities({ | |
| 2111 | provider: driver, | |
| 2112 | packageName: null, | |
| 2113 | }), | |
| 2114 | ), | |
| 2115 | getSnapshot: Effect.succeed(provider), | |
| 2116 | refresh: Effect.gen(function* () { | |
| 2117 | yield* Ref.update(refreshCalls, (count) => count + 1); | |
| 2118 | yield* Deferred.succeed(probeStarted, undefined); | |
| 2119 | yield* Deferred.await(releaseProbe); | |
| 2120 | return provider; | |
| 2121 | }), | |
| 2122 | streamChanges: Stream.empty, | |
| 2123 | applyUsageLimits: () => Effect.void, | |
| 2124 | }, | |
| 2125 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | |
| 2126 | textGeneration: {} as ProviderInstance["textGeneration"], | |
| 2127 | }, | |
| 2128 | ] satisfies ReadonlyArray<ProviderInstance>; | |
| 2129 | const instanceRegistryLayer = Layer.succeed( | |
| 2130 | ProviderInstanceRegistry.ProviderInstanceRegistry, | |
| 2131 | { | |
| 2132 | getInstance: (requestedId) => | |
| 2133 | Effect.succeed(instances.find((instance) => instance.instanceId === requestedId)), | |
| 2134 | listInstances: Effect.succeed(instances), | |
| 2135 | listUnavailable: Effect.succeed([]), | |
| 2136 | streamChanges: Stream.empty, | |
| 2137 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | |
| 2138 | }, | |
| 2139 | ); | |
| 2140 | const scope = yield* Scope.make(); | |
| 2141 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | |
| 2142 | const runtimeServices = yield* Layer.build( | |
| 2143 | ProviderRegistry.layer.pipe( | |
| 2144 | Layer.provideMerge(instanceRegistryLayer), | |
| 2145 | Layer.provideMerge( | |
| 2146 | ServerConfig.layerTest(process.cwd(), { | |
| 2147 | prefix: "t3-provider-registry-shared-refresh-", | |
| 2148 | }), | |
| 2149 | ), | |
| 2150 | Layer.provideMerge(NodeServices.layer), | |
| 2151 | ), | |
| 2152 | ).pipe(Scope.provide(scope)); | |
| 2153 | ||
| 2154 | yield* Effect.gen(function* () { | |
| 2155 | const registry = yield* ProviderRegistry.ProviderRegistry; | |
| 2156 | const first = yield* registry.refresh().pipe(Effect.forkChild); | |
| 2157 | yield* Deferred.await(probeStarted); | |
| 2158 | const second = yield* registry.refresh().pipe(Effect.forkChild); | |
| 2159 | yield* Effect.yieldNow; | |
| 2160 | assert.strictEqual(yield* Ref.get(refreshCalls), 1); | |
| 2161 | yield* Deferred.succeed(releaseProbe, undefined); | |
| 2162 | expect(yield* Fiber.join(first)).toEqual([expect.objectContaining(provider)]); | |
| 2163 | expect(yield* Fiber.join(second)).toEqual([expect.objectContaining(provider)]); | |
| 2164 | assert.strictEqual(yield* Ref.get(refreshCalls), 1); | |
| 2165 | ||
| 2166 | yield* registry.refresh(); | |
| 2167 | assert.strictEqual(yield* Ref.get(refreshCalls), 2); | |
| 2168 | }).pipe(Effect.provide(runtimeServices)); | |
| 2169 | }), | |
| 2170 | ); | |
| 2171 | ||
| 2080 | 2172 | it.effect("persists the merged snapshot when a live update has empty models", () => |
| 2081 | 2173 | Effect.gen(function* () { |
| 2082 | 2174 | const cursorDriver = ProviderDriverKind.make("cursor"); |
apps/server/src/provider/ProviderRegistry.ts+45-5| ... | ... | @@ -36,10 +36,12 @@ import { |
| 36 | 36 | } from "@t3tools/contracts"; |
| 37 | 37 | import * as Cause from "effect/Cause"; |
| 38 | 38 | import * as Context from "effect/Context"; |
| 39 | import * as Deferred from "effect/Deferred"; | |
| 39 | 40 | import * as Effect from "effect/Effect"; |
| 40 | 41 | import * as Equal from "effect/Equal"; |
| 41 | 42 | import * as FileSystem from "effect/FileSystem"; |
| 42 | 43 | import * as Layer from "effect/Layer"; |
| 44 | import * as Option from "effect/Option"; | |
| 43 | 45 | import * as Path from "effect/Path"; |
| 44 | 46 | import * as PubSub from "effect/PubSub"; |
| 45 | 47 | import * as Ref from "effect/Ref"; |
| ... | ... | @@ -701,12 +703,50 @@ export const layer = Layer.effect( |
| 701 | 703 | ); |
| 702 | 704 | }); |
| 703 | 705 | |
| 706 | type RefreshAllCompletion = Deferred.Deferred<ReadonlyArray<ServerProvider>>; | |
| 707 | const refreshAllInFlightRef = yield* Ref.make<Option.Option<RefreshAllCompletion>>( | |
| 708 | Option.none(), | |
| 709 | ); | |
| 710 | ||
| 711 | // Untargeted refreshes probe every live source, so any read-scoped client | |
| 712 | // can request them. Concurrent callers share one in-flight pass instead of | |
| 713 | // each starting their own set of provider processes. | |
| 704 | 714 | const refreshAll = Effect.fn("refreshAll")(function* () { |
| 705 | const sources = yield* getLiveSources; | |
| 706 | return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { | |
| 707 | concurrency: "unbounded", | |
| 708 | discard: true, | |
| 709 | }).pipe(Effect.andThen(Ref.get(providersRef))); | |
| 715 | const claimed = yield* Ref.modify( | |
| 716 | refreshAllInFlightRef, | |
| 717 | ( | |
| 718 | inFlight, | |
| 719 | ): readonly [ | |
| 720 | { readonly owner: boolean; readonly completion: RefreshAllCompletion }, | |
| 721 | Option.Option<RefreshAllCompletion>, | |
| 722 | ] => { | |
| 723 | if (Option.isSome(inFlight)) { | |
| 724 | return [{ owner: false, completion: inFlight.value }, inFlight]; | |
| 725 | } | |
| 726 | const completion = Deferred.makeUnsafe<ReadonlyArray<ServerProvider>>(); | |
| 727 | return [{ owner: true, completion }, Option.some(completion)]; | |
| 728 | }, | |
| 729 | ); | |
| 730 | if (claimed.owner) { | |
| 731 | // Detached so an interrupted first caller cannot cancel the pass that | |
| 732 | // other callers are already awaiting. | |
| 733 | yield* getLiveSources.pipe( | |
| 734 | Effect.flatMap((sources) => | |
| 735 | Effect.forEach(sources, (source) => refreshOneSource(source), { | |
| 736 | concurrency: "unbounded", | |
| 737 | discard: true, | |
| 738 | }), | |
| 739 | ), | |
| 740 | Effect.andThen(Ref.get(providersRef)), | |
| 741 | Effect.onExit((exit) => | |
| 742 | Ref.set(refreshAllInFlightRef, Option.none()).pipe( | |
| 743 | Effect.andThen(Deferred.done(claimed.completion, exit)), | |
| 744 | ), | |
| 745 | ), | |
| 746 | Effect.forkDetach, | |
| 747 | ); | |
| 748 | } | |
| 749 | return yield* Deferred.await(claimed.completion); | |
| 710 | 750 | }); |
| 711 | 751 | |
| 712 | 752 | const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { |
apps/server/src/serverSettings.test.ts+85| ... | ... | @@ -686,6 +686,91 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 686 | 686 | }).pipe(Effect.provide(layerServerSettings())), |
| 687 | 687 | ); |
| 688 | 688 | |
| 689 | // Only driver-keyed instances are fallback candidates; a custom instance id | |
| 690 | // is not one, so the selection stays put until the user changes it. | |
| 691 | it.effect.each(["codex"])( | |
| 692 | "falls back to enabled instance %s after disabling the selection", | |
| 693 | (fallbackId) => | |
| 694 | Effect.scoped( | |
| 695 | Effect.gen(function* () { | |
| 696 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 697 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 698 | const fileSystem = yield* FileSystem.FileSystem; | |
| 699 | const writerId = ProviderInstanceId.make("writer"); | |
| 700 | const fallbackInstanceId = ProviderInstanceId.make(fallbackId); | |
| 701 | const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; | |
| 702 | const providerInstances = { | |
| 703 | [fallbackInstanceId]: { | |
| 704 | driver: ProviderDriverKind.make("codex"), | |
| 705 | enabled: true, | |
| 706 | config: {}, | |
| 707 | }, | |
| 708 | [writerId]: { | |
| 709 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 710 | enabled: true, | |
| 711 | config: {}, | |
| 712 | }, | |
| 713 | }; | |
| 714 | ||
| 715 | yield* serverSettings.updateSettings({ | |
| 716 | providers: Object.fromEntries( | |
| 717 | Object.keys(DEFAULT_SERVER_SETTINGS.providers).map( | |
| 718 | (provider) => [provider, { enabled: false }] as const, | |
| 719 | ), | |
| 720 | ), | |
| 721 | providerInstances, | |
| 722 | textGenerationModelSelection: selection, | |
| 723 | }); | |
| 724 | const changes = yield* serverSettings.subscribeChanges; | |
| 725 | ||
| 726 | const next = yield* serverSettings.updateSettings({ | |
| 727 | providerInstances: { | |
| 728 | ...providerInstances, | |
| 729 | [writerId]: { ...providerInstances[writerId]!, enabled: false }, | |
| 730 | }, | |
| 731 | }); | |
| 732 | const fallbackSelection = { | |
| 733 | instanceId: fallbackInstanceId, | |
| 734 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 735 | }; | |
| 736 | assert.deepEqual(next.textGenerationModelSelection, fallbackSelection); | |
| 737 | assert.deepEqual( | |
| 738 | (yield* serverSettings.getSettings).textGenerationModelSelection, | |
| 739 | fallbackSelection, | |
| 740 | ); | |
| 741 | const change = Option.getOrUndefined(yield* Stream.runHead(changes)); | |
| 742 | assert.deepEqual(change?.textGenerationModelSelection, fallbackSelection); | |
| 743 | ||
| 744 | const persisted = yield* fileSystem | |
| 745 | .readFileString(serverConfig.settingsPath) | |
| 746 | .pipe( | |
| 747 | Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))), | |
| 748 | ); | |
| 749 | assert.deepEqual(persisted.textGenerationModelSelection, selection); | |
| 750 | ||
| 751 | const restored = yield* serverSettings.updateSettings({ providerInstances }); | |
| 752 | assert.deepEqual(restored.textGenerationModelSelection, selection); | |
| 753 | }), | |
| 754 | ).pipe(Effect.provide(layerServerSettings())), | |
| 755 | ); | |
| 756 | ||
| 757 | it.effect("skips explicitly disabled instances when choosing a legacy fallback", () => | |
| 758 | Effect.gen(function* () { | |
| 759 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 760 | const next = yield* serverSettings.updateSettings({ | |
| 761 | providerInstances: { | |
| 762 | [ProviderInstanceId.make("codex")]: { | |
| 763 | driver: ProviderDriverKind.make("codex"), | |
| 764 | enabled: false, | |
| 765 | config: {}, | |
| 766 | }, | |
| 767 | }, | |
| 768 | }); | |
| 769 | ||
| 770 | assert.equal(next.textGenerationModelSelection.instanceId, "claudeAgent"); | |
| 771 | }).pipe(Effect.provide(layerServerSettings())), | |
| 772 | ); | |
| 773 | ||
| 689 | 774 | it.effect("preserves enabled text generation selections for non-built-in drivers", () => |
| 690 | 775 | Effect.gen(function* () { |
| 691 | 776 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
apps/web/src/cloud/primaryCloudLinkState.ts+20-4| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | AuthRelayReadScope, | |
| 4 | EnvironmentId, | |
| 5 | type EnvironmentCloudLinkStateResult, | |
| 6 | } from "@t3tools/contracts"; | |
| 3 | 7 | import * as Cause from "effect/Cause"; |
| 4 | 8 | import * as Context from "effect/Context"; |
| 5 | 9 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -12,6 +16,7 @@ import { useCallback, useMemo } from "react"; |
| 12 | 16 | import { usePrimaryEnvironment } from "../state/environments"; |
| 13 | 17 | import { runtime } from "../lib/runtime"; |
| 14 | 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 19 | import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; | |
| 15 | 20 | import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; |
| 16 | 21 | import { hasCloudPublicConfig } from "./publicConfig"; |
| 17 | 22 | |
| ... | ... | @@ -44,13 +49,24 @@ function targetKey(target: CloudLinkTarget): string { |
| 44 | 49 | } |
| 45 | 50 | |
| 46 | 51 | function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { |
| 47 | if (target) { | |
| 52 | if ( | |
| 53 | target && | |
| 54 | readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope) | |
| 55 | ) { | |
| 48 | 56 | appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); |
| 49 | 57 | } |
| 50 | 58 | } |
| 51 | 59 | |
| 60 | export function readCachedPrimaryCloudLinkState(target: CloudLinkTarget) { | |
| 61 | if (!readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope)) | |
| 62 | return null; | |
| 63 | const result = appAtomRegistry.get(primaryCloudLinkStateAtom(targetKey(target))); | |
| 64 | return result._tag === "Success" ? result.value : null; | |
| 65 | } | |
| 66 | ||
| 52 | 67 | export function usePrimaryCloudLinkState() { |
| 53 | 68 | const primary = usePrimaryEnvironment(); |
| 69 | const canReadRelay = useEnvironmentScope(primary?.environmentId ?? null, AuthRelayReadScope); | |
| 54 | 70 | const target = useMemo( |
| 55 | 71 | () => |
| 56 | 72 | primary?.entry.target._tag === "PrimaryConnectionTarget" |
| ... | ... | @@ -65,7 +81,7 @@ export function usePrimaryCloudLinkState() { |
| 65 | 81 | ); |
| 66 | 82 | // Builds without T3 Connect have no link to read; skip the request. |
| 67 | 83 | const atom = |
| 68 | target && hasCloudPublicConfig() | |
| 84 | target && hasCloudPublicConfig() && canReadRelay | |
| 69 | 85 | ? primaryCloudLinkStateAtom(targetKey(target)) |
| 70 | 86 | : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; |
| 71 | 87 | const result = useAtomValue(atom); |
| ... | ... | @@ -79,7 +95,7 @@ export function usePrimaryCloudLinkState() { |
| 79 | 95 | } |
| 80 | 96 | |
| 81 | 97 | return { |
| 82 | data: Option.getOrNull(AsyncResult.value(result)), | |
| 98 | data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)), | |
| 83 | 99 | error, |
| 84 | 100 | isPending: result.waiting, |
| 85 | 101 | refresh, |
apps/web/src/cloud/useCloudLinkController.test.tsx created+230| ... | ... | @@ -0,0 +1,230 @@ |
| 1 | import { | |
| 2 | AuthRelayReadScope, | |
| 3 | AuthRelayWriteScope, | |
| 4 | EnvironmentId, | |
| 5 | type EnvironmentCloudLinkStateResult, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { AsyncResult } from "effect/reactivity"; | |
| 8 | import { act, useLayoutEffect } from "react"; | |
| 9 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | const testState = vi.hoisted(() => ({ | |
| 13 | scopes: new Set<string>(), | |
| 14 | snapshot: null as EnvironmentCloudLinkStateResult | null, | |
| 15 | renderedSnapshot: null as EnvironmentCloudLinkStateResult | null, | |
| 16 | getToken: vi.fn<() => Promise<string | null>>(), | |
| 17 | link: vi.fn(), | |
| 18 | unlink: vi.fn(), | |
| 19 | preferences: vi.fn(), | |
| 20 | refreshDiscovery: vi.fn(), | |
| 21 | refreshLink: vi.fn(), | |
| 22 | toast: vi.fn(), | |
| 23 | })); | |
| 24 | ||
| 25 | vi.mock("@clerk/react", () => ({ | |
| 26 | useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }), | |
| 27 | })); | |
| 28 | vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } })); | |
| 29 | vi.mock("../state/relay", () => ({ | |
| 30 | relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, | |
| 31 | })); | |
| 32 | vi.mock("../state/session", () => ({ | |
| 33 | readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope), | |
| 34 | })); | |
| 35 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); | |
| 36 | vi.mock("./linkEnvironmentAtoms", () => ({ | |
| 37 | linkPrimaryEnvironment: testState.link, | |
| 38 | unlinkPrimaryEnvironment: testState.unlink, | |
| 39 | updatePrimaryEnvironmentPreferences: testState.preferences, | |
| 40 | })); | |
| 41 | vi.mock("./primaryCloudLinkState", () => ({ | |
| 42 | readCachedPrimaryCloudLinkState: () => testState.snapshot, | |
| 43 | usePrimaryCloudLinkState: () => ({ | |
| 44 | target, | |
| 45 | data: testState.renderedSnapshot, | |
| 46 | error: null, | |
| 47 | isPending: false, | |
| 48 | refresh: testState.refreshLink, | |
| 49 | }), | |
| 50 | })); | |
| 51 | vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) })); | |
| 52 | ||
| 53 | import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController"; | |
| 54 | ||
| 55 | const target = { | |
| 56 | environmentId: EnvironmentId.make("primary"), | |
| 57 | label: "Primary", | |
| 58 | httpBaseUrl: "http://localhost:3773", | |
| 59 | wsBaseUrl: "ws://localhost:3773/ws", | |
| 60 | }; | |
| 61 | const linkedState: EnvironmentCloudLinkStateResult = { | |
| 62 | linked: true, | |
| 63 | cloudUserId: "account-1", | |
| 64 | relayUrl: "https://relay.example.com", | |
| 65 | relayIssuer: "https://relay.example.com", | |
| 66 | managedTunnelActive: true, | |
| 67 | publishAgentActivity: false, | |
| 68 | }; | |
| 69 | ||
| 70 | let renderer: ReactTestRenderer | null = null; | |
| 71 | let controller: ReturnType<typeof useCloudLinkController> | null = null; | |
| 72 | ||
| 73 | function ControllerProbe() { | |
| 74 | const value = useCloudLinkController(); | |
| 75 | useLayoutEffect(() => { | |
| 76 | controller = value; | |
| 77 | }); | |
| 78 | return null; | |
| 79 | } | |
| 80 | ||
| 81 | async function mountController() { | |
| 82 | await act(() => { | |
| 83 | renderer = create(<ControllerProbe />); | |
| 84 | }); | |
| 85 | } | |
| 86 | ||
| 87 | async function reconcile(desired: CloudLinkDesiredState) { | |
| 88 | let succeeded = false; | |
| 89 | await act(async () => { | |
| 90 | if (controller === null) throw new Error("Controller is not mounted."); | |
| 91 | succeeded = await controller.reconcileCloudState(desired); | |
| 92 | }); | |
| 93 | return succeeded; | |
| 94 | } | |
| 95 | ||
| 96 | function expectNoMutations() { | |
| 97 | expect(testState.link).not.toHaveBeenCalled(); | |
| 98 | expect(testState.unlink).not.toHaveBeenCalled(); | |
| 99 | expect(testState.preferences).not.toHaveBeenCalled(); | |
| 100 | } | |
| 101 | ||
| 102 | beforeEach(() => { | |
| 103 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 104 | vi.spyOn(console, "error").mockImplementation(() => undefined); | |
| 105 | testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); | |
| 106 | testState.snapshot = linkedState; | |
| 107 | testState.renderedSnapshot = linkedState; | |
| 108 | testState.getToken.mockReset().mockResolvedValue("clerk-token"); | |
| 109 | for (const command of [ | |
| 110 | testState.link, | |
| 111 | testState.unlink, | |
| 112 | testState.preferences, | |
| 113 | testState.refreshDiscovery, | |
| 114 | ]) { | |
| 115 | command.mockReset().mockResolvedValue(AsyncResult.success({})); | |
| 116 | } | |
| 117 | testState.refreshLink.mockReset(); | |
| 118 | testState.toast.mockReset(); | |
| 119 | }); | |
| 120 | ||
| 121 | afterEach(async () => { | |
| 122 | await act(() => renderer?.unmount()); | |
| 123 | renderer = null; | |
| 124 | controller = null; | |
| 125 | vi.restoreAllMocks(); | |
| 126 | vi.unstubAllGlobals(); | |
| 127 | }); | |
| 128 | ||
| 129 | describe("useCloudLinkController", () => { | |
| 130 | it.each([AuthRelayReadScope, AuthRelayWriteScope])( | |
| 131 | "does not mutate without %s, even with a cached link snapshot", | |
| 132 | async (scope) => { | |
| 133 | await mountController(); | |
| 134 | testState.scopes.delete(scope); | |
| 135 | ||
| 136 | expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); | |
| 137 | expect(testState.getToken).not.toHaveBeenCalled(); | |
| 138 | expectNoMutations(); | |
| 139 | }, | |
| 140 | ); | |
| 141 | ||
| 142 | it("does not interpret unavailable link state as an unlinked environment", async () => { | |
| 143 | testState.snapshot = null; | |
| 144 | await mountController(); | |
| 145 | ||
| 146 | expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); | |
| 147 | expect(testState.getToken).not.toHaveBeenCalled(); | |
| 148 | expectNoMutations(); | |
| 149 | }); | |
| 150 | ||
| 151 | it.each([AuthRelayReadScope, AuthRelayWriteScope])( | |
| 152 | "rechecks %s after requesting the cloud token", | |
| 153 | async (scope) => { | |
| 154 | await mountController(); | |
| 155 | testState.getToken.mockImplementationOnce(async () => { | |
| 156 | testState.scopes.delete(scope); | |
| 157 | return "clerk-token"; | |
| 158 | }); | |
| 159 | ||
| 160 | expect(await reconcile({ managedTunnel: false, publish: false })).toBe(false); | |
| 161 | expectNoMutations(); | |
| 162 | }, | |
| 163 | ); | |
| 164 | ||
| 165 | it("stops if the link-state read fails while requesting the cloud token", async () => { | |
| 166 | await mountController(); | |
| 167 | testState.getToken.mockImplementationOnce(async () => { | |
| 168 | testState.snapshot = null; | |
| 169 | return "clerk-token"; | |
| 170 | }); | |
| 171 | ||
| 172 | expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); | |
| 173 | expectNoMutations(); | |
| 174 | }); | |
| 175 | ||
| 176 | it("uses the latest linked mode to change publishing without replacing the tunnel", async () => { | |
| 177 | testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; | |
| 178 | testState.renderedSnapshot = testState.snapshot; | |
| 179 | await mountController(); | |
| 180 | testState.getToken.mockImplementationOnce(async () => { | |
| 181 | testState.snapshot = linkedState; | |
| 182 | return "clerk-token"; | |
| 183 | }); | |
| 184 | ||
| 185 | expect(await reconcile({ managedTunnel: true, publish: true })).toBe(true); | |
| 186 | expect(testState.link).not.toHaveBeenCalled(); | |
| 187 | expect(testState.unlink).not.toHaveBeenCalled(); | |
| 188 | expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({ | |
| 189 | target, | |
| 190 | publishAgentActivity: true, | |
| 191 | }); | |
| 192 | }); | |
| 193 | ||
| 194 | it("stops the preference write if management permission is lost after linking", async () => { | |
| 195 | await mountController(); | |
| 196 | testState.link.mockImplementationOnce(async () => { | |
| 197 | testState.scopes.delete(AuthRelayWriteScope); | |
| 198 | return AsyncResult.success({}); | |
| 199 | }); | |
| 200 | ||
| 201 | expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); | |
| 202 | expect(testState.link).toHaveBeenCalledOnce(); | |
| 203 | expect(testState.preferences).not.toHaveBeenCalled(); | |
| 204 | expect(testState.refreshLink).toHaveBeenCalledOnce(); | |
| 205 | }); | |
| 206 | ||
| 207 | it("still lets an authorized user unlink when the cloud token is unavailable", async () => { | |
| 208 | await mountController(); | |
| 209 | testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable")); | |
| 210 | ||
| 211 | expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); | |
| 212 | expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); | |
| 213 | expect(testState.link).not.toHaveBeenCalled(); | |
| 214 | expect(testState.preferences).not.toHaveBeenCalled(); | |
| 215 | }); | |
| 216 | ||
| 217 | it("unlinks if the link-state read fails while requesting the cloud token", async () => { | |
| 218 | await mountController(); | |
| 219 | testState.getToken.mockImplementationOnce(async () => { | |
| 220 | testState.snapshot = null; | |
| 221 | throw new Error("Cloud sign-in unavailable"); | |
| 222 | }); | |
| 223 | ||
| 224 | expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); | |
| 225 | expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); | |
| 226 | expect(testState.link).not.toHaveBeenCalled(); | |
| 227 | expect(testState.preferences).not.toHaveBeenCalled(); | |
| 228 | expect(testState.refreshLink).toHaveBeenCalledOnce(); | |
| 229 | }); | |
| 230 | }); |
apps/web/src/cloud/useCloudLinkController.ts+35-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { useAuth } from "@clerk/react"; |
| 2 | 2 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; |
| 3 | import { EnvironmentId, AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts"; | |
| 3 | 4 | import { |
| 4 | 5 | isAtomCommandInterrupted, |
| 5 | 6 | settlePromise, |
| ... | ... | @@ -9,13 +10,14 @@ import { useState } from "react"; |
| 9 | 10 | |
| 10 | 11 | import { toastManager } from "../components/ui/toast"; |
| 11 | 12 | import { relayEnvironmentDiscovery } from "../state/relay"; |
| 13 | import { readEnvironmentScope } from "../state/session"; | |
| 12 | 14 | import { useAtomCommand } from "../state/use-atom-command"; |
| 13 | 15 | import { |
| 14 | 16 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, |
| 15 | 17 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, |
| 16 | 18 | updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, |
| 17 | 19 | } from "./linkEnvironmentAtoms"; |
| 18 | import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; | |
| 20 | import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState"; | |
| 19 | 21 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 20 | 22 | |
| 21 | 23 | export interface CloudLinkDesiredState { |
| ... | ... | @@ -87,8 +89,31 @@ export function useCloudLinkController() { |
| 87 | 89 | reportUpdateFailure(new Error("Local environment is not ready yet.")); |
| 88 | 90 | return false; |
| 89 | 91 | } |
| 90 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | |
| 92 | const environmentId = EnvironmentId.make(target.environmentId); | |
| 93 | const canManageLink = () => { | |
| 94 | if ( | |
| 95 | !readEnvironmentScope(environmentId, AuthRelayReadScope) || | |
| 96 | !readEnvironmentScope(environmentId, AuthRelayWriteScope) | |
| 97 | ) { | |
| 98 | reportUpdateFailure( | |
| 99 | new Error("This connection needs permission to view and manage T3 Connect settings."), | |
| 100 | ); | |
| 101 | return false; | |
| 102 | } | |
| 103 | return true; | |
| 104 | }; | |
| 105 | const readLinkState = () => { | |
| 106 | const state = readCachedPrimaryCloudLinkState(target); | |
| 107 | if (state === null) { | |
| 108 | reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read.")); | |
| 109 | } | |
| 110 | return state; | |
| 111 | }; | |
| 112 | if (!canManageLink()) return false; | |
| 91 | 113 | const wantsLink = desired.managedTunnel || desired.publish; |
| 114 | if (wantsLink && readLinkState() === null) return false; | |
| 115 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | |
| 116 | if (!canManageLink()) return false; | |
| 92 | 117 | |
| 93 | 118 | // A failure after this point may follow a partially applied mutation (e.g. |
| 94 | 119 | // the link succeeded but the preference update did not), so every exit — |
| ... | ... | @@ -109,6 +134,8 @@ export function useCloudLinkController() { |
| 109 | 134 | return false; |
| 110 | 135 | } |
| 111 | 136 | } else { |
| 137 | const currentLinkState = readLinkState(); | |
| 138 | if (currentLinkState === null) return false; | |
| 112 | 139 | if (tokenResult._tag === "Failure") { |
| 113 | 140 | reportUpdateFailure(squashAtomCommandFailure(tokenResult)); |
| 114 | 141 | return false; |
| ... | ... | @@ -118,7 +145,8 @@ export function useCloudLinkController() { |
| 118 | 145 | reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); |
| 119 | 146 | return false; |
| 120 | 147 | } |
| 121 | if (!linked || managedTunnelActive !== desired.managedTunnel) { | |
| 148 | const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked; | |
| 149 | if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { | |
| 122 | 150 | const linkResult = await linkPrimaryEnvironment({ |
| 123 | 151 | target, |
| 124 | 152 | clerkToken, |
| ... | ... | @@ -132,6 +160,10 @@ export function useCloudLinkController() { |
| 132 | 160 | return false; |
| 133 | 161 | } |
| 134 | 162 | } |
| 163 | if (!canManageLink() || readLinkState() === null) { | |
| 164 | primaryCloudLinkState.refresh(); | |
| 165 | return false; | |
| 166 | } | |
| 135 | 167 | const prefResult = await updatePrimaryEnvironmentPreferences({ |
| 136 | 168 | target, |
| 137 | 169 | publishAgentActivity: desired.publish, |
apps/web/src/components/ChatMarkdown.permissions.test.tsx created+299| ... | ... | @@ -0,0 +1,299 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | EnvironmentId, | |
| 4 | ProjectId, | |
| 5 | ThreadId, | |
| 6 | type AuthEnvironmentScope, | |
| 7 | type EditorId, | |
| 8 | type ThreadLinkedPullRequest, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | |
| 11 | import { AsyncResult } from "effect/reactivity"; | |
| 12 | import { cloneElement, type ReactElement, type ReactNode } from "react"; | |
| 13 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 14 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 15 | ||
| 16 | const state = vi.hoisted(() => ({ | |
| 17 | allowed: true, | |
| 18 | listeners: new Set<() => void>(), | |
| 19 | openEditor: vi.fn(), | |
| 20 | updateMetadata: vi.fn(), | |
| 21 | search: vi.fn(), | |
| 22 | choose: vi.fn(), | |
| 23 | openFile: vi.fn(), | |
| 24 | openExternal: vi.fn(), | |
| 25 | copy: vi.fn(), | |
| 26 | toast: vi.fn(), | |
| 27 | linkedPullRequest: null as ThreadLinkedPullRequest | null, | |
| 28 | })); | |
| 29 | ||
| 30 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); | |
| 31 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | |
| 32 | vi.mock("../hooks/useSettings", () => ({ | |
| 33 | getClientSettings: () => DEFAULT_CLIENT_SETTINGS, | |
| 34 | useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => | |
| 35 | select ? select(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS, | |
| 36 | })); | |
| 37 | vi.mock("./ui/tooltip", () => ({ | |
| 38 | Tooltip: ({ children }: { children: ReactNode }) => children, | |
| 39 | TooltipTrigger: ({ render, children }: { render: ReactElement; children?: ReactNode }) => | |
| 40 | children === undefined ? render : cloneElement(render, undefined, children), | |
| 41 | TooltipPopup: () => null, | |
| 42 | })); | |
| 43 | vi.mock("./chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); | |
| 44 | vi.mock("./ui/toast", () => ({ | |
| 45 | toastManager: { add: state.toast }, | |
| 46 | stackedThreadToast: (value: unknown) => value, | |
| 47 | })); | |
| 48 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.search })); | |
| 49 | vi.mock("../state/use-atom-command", () => ({ | |
| 50 | useAtomCommand: (command: string) => | |
| 51 | command === "updateMetadata" ? state.updateMetadata : state.openEditor, | |
| 52 | })); | |
| 53 | vi.mock("../state/session", async () => { | |
| 54 | const { useSyncExternalStore } = await import("react"); | |
| 55 | const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => | |
| 56 | id !== null && | |
| 57 | (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed); | |
| 58 | return { | |
| 59 | // Asset atoms read these at module scope; the markdown tests only exercise | |
| 60 | // the task grant. | |
| 61 | environmentSession: { | |
| 62 | sessionStateAtom: () => ({}), | |
| 63 | preparedConnectionValueAtom: () => ({}), | |
| 64 | }, | |
| 65 | readEnvironmentScope, | |
| 66 | useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => | |
| 67 | useSyncExternalStore( | |
| 68 | (listener) => { | |
| 69 | state.listeners.add(listener); | |
| 70 | return () => state.listeners.delete(listener); | |
| 71 | }, | |
| 72 | () => readEnvironmentScope(id, scope), | |
| 73 | ), | |
| 74 | usePreparedConnection: () => ({ _tag: "None" }), | |
| 75 | }; | |
| 76 | }); | |
| 77 | vi.mock("../state/server", () => ({ | |
| 78 | serverEnvironment: { configValueAtom: () => "config" }, | |
| 79 | })); | |
| 80 | vi.mock("../state/threads", () => ({ | |
| 81 | threadEnvironment: { updateMetadata: "updateMetadata" }, | |
| 82 | })); | |
| 83 | vi.mock("../state/entities", () => ({ | |
| 84 | readEnvironmentSupportsServerBrowser: () => false, | |
| 85 | useServerConfigs: () => new Map([[threadRef.environmentId, serverConfig]]), | |
| 86 | readThreadShell: () => ({ linkedPullRequest: state.linkedPullRequest }), | |
| 87 | useProjects: () => [{ id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }], | |
| 88 | })); | |
| 89 | vi.mock("../rightPanelStore", () => ({ | |
| 90 | useRightPanelStore: { getState: () => ({ openFile: state.openFile }) }, | |
| 91 | })); | |
| 92 | vi.mock("../remoteOpen", () => ({ | |
| 93 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), | |
| 94 | })); | |
| 95 | vi.mock("../hooks/useLocalStorage", () => ({ | |
| 96 | useLocalStorage: () => ["vscode", vi.fn()], | |
| 97 | getLocalStorageItem: () => "vscode", | |
| 98 | setLocalStorageItem: vi.fn(), | |
| 99 | })); | |
| 100 | vi.mock("../hooks/useCopyToClipboard", () => ({ | |
| 101 | writeTextToClipboard: state.copy, | |
| 102 | useCopyToClipboard: () => ({ copyToClipboard: state.copy, isCopied: false }), | |
| 103 | })); | |
| 104 | vi.mock("../localApi", () => ({ | |
| 105 | readLocalApi: () => ({ | |
| 106 | contextMenu: { show: state.choose }, | |
| 107 | shell: { openExternal: state.openExternal }, | |
| 108 | }), | |
| 109 | })); | |
| 110 | vi.mock("~/lib/openPullRequestLink", () => ({ | |
| 111 | resolvePullRequestPreviewTarget: () => null, | |
| 112 | findProjectForChangeRequest: (projects: readonly { id: ProjectId }[]) => projects[0], | |
| 113 | matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => | |
| 114 | candidate.url === href, | |
| 115 | parseChangeRequestUrl: (href: string) => | |
| 116 | href === linkedPullRequest.url | |
| 117 | ? { | |
| 118 | host: "github.com", | |
| 119 | repository: linkedPullRequest.repository, | |
| 120 | number: linkedPullRequest.number, | |
| 121 | } | |
| 122 | : null, | |
| 123 | useOpenChangeRequestLink: () => vi.fn(), | |
| 124 | })); | |
| 125 | ||
| 126 | import ChatMarkdown from "./ChatMarkdown"; | |
| 127 | ||
| 128 | const threadRef = { | |
| 129 | environmentId: EnvironmentId.make("selected"), | |
| 130 | threadId: ThreadId.make("thread"), | |
| 131 | }; | |
| 132 | const linkedPullRequest: ThreadLinkedPullRequest = { | |
| 133 | projectId: ProjectId.make("project"), | |
| 134 | repository: "example/repo", | |
| 135 | number: 42, | |
| 136 | url: "https://github.com/example/repo/pull/42", | |
| 137 | }; | |
| 138 | const serverConfig = { | |
| 139 | availableEditors: ["vscode", "file-manager"] as readonly EditorId[], | |
| 140 | shellRevealInFileManager: true, | |
| 141 | shellRevealInFileManagerKind: "xdg-open", | |
| 142 | environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } }, | |
| 143 | }; | |
| 144 | let renderer: ReactTestRenderer | undefined; | |
| 145 | ||
| 146 | beforeEach(() => { | |
| 147 | state.allowed = true; | |
| 148 | state.listeners.clear(); | |
| 149 | state.linkedPullRequest = null; | |
| 150 | state.openEditor.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 151 | state.updateMetadata.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 152 | state.search.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); | |
| 153 | state.choose.mockReset().mockResolvedValue(null); | |
| 154 | state.openFile.mockReset(); | |
| 155 | state.openExternal.mockReset().mockResolvedValue(undefined); | |
| 156 | state.copy.mockReset().mockResolvedValue(undefined); | |
| 157 | state.toast.mockReset(); | |
| 158 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 159 | vi.stubGlobal("window", {}); | |
| 160 | vi.stubGlobal("navigator", { clipboard: { writeText: state.copy } }); | |
| 161 | }); | |
| 162 | ||
| 163 | afterEach(async () => { | |
| 164 | await act(async () => renderer?.unmount()); | |
| 165 | renderer = undefined; | |
| 166 | vi.unstubAllGlobals(); | |
| 167 | }); | |
| 168 | ||
| 169 | async function renderMarkdown(text: string) { | |
| 170 | await act(async () => { | |
| 171 | renderer = create(<ChatMarkdown cwd="/work" threadRef={threadRef} text={text} />); | |
| 172 | }); | |
| 173 | } | |
| 174 | ||
| 175 | function menuEvent() { | |
| 176 | return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 10, clientY: 10 }; | |
| 177 | } | |
| 178 | ||
| 179 | function anchor() { | |
| 180 | return renderer!.root.findByType("a"); | |
| 181 | } | |
| 182 | ||
| 183 | async function openContextMenu() { | |
| 184 | await act(async () => anchor().props.onContextMenu(menuEvent())); | |
| 185 | } | |
| 186 | ||
| 187 | function offeredActions() { | |
| 188 | return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id); | |
| 189 | } | |
| 190 | ||
| 191 | it("removes host actions after revocation while keeping file preview and copying", async () => { | |
| 192 | await renderMarkdown("[Readme](docs/readme.md)"); | |
| 193 | await openContextMenu(); | |
| 194 | expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); | |
| 195 | await act(async () => { | |
| 196 | state.allowed = false; | |
| 197 | for (const listener of state.listeners) listener(); | |
| 198 | }); | |
| 199 | state.choose.mockResolvedValue("copy-full"); | |
| 200 | await openContextMenu(); | |
| 201 | expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); | |
| 202 | expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md"); | |
| 203 | await act(async () => anchor().props.onClick(menuEvent())); | |
| 204 | expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); | |
| 205 | expect(state.openEditor).not.toHaveBeenCalled(); | |
| 206 | }); | |
| 207 | ||
| 208 | it("does not launch an editor after revocation during a native context menu", async () => { | |
| 209 | let choose: (action: string) => void = () => { | |
| 210 | throw new Error("Menu not opened"); | |
| 211 | }; | |
| 212 | state.choose.mockImplementationOnce( | |
| 213 | () => | |
| 214 | new Promise<string>((resolve) => { | |
| 215 | choose = resolve; | |
| 216 | }), | |
| 217 | ); | |
| 218 | await renderMarkdown("[Readme](docs/readme.md)"); | |
| 219 | await openContextMenu(); | |
| 220 | expect(offeredActions()).toContain("open"); | |
| 221 | await act(async () => { | |
| 222 | state.allowed = false; | |
| 223 | choose("open"); | |
| 224 | }); | |
| 225 | expect(state.openEditor).not.toHaveBeenCalled(); | |
| 226 | }); | |
| 227 | ||
| 228 | it("does not reveal a file after revocation during its workspace lookup", async () => { | |
| 229 | let finishLookup: () => void = () => { | |
| 230 | throw new Error("Lookup not started"); | |
| 231 | }; | |
| 232 | state.search.mockImplementationOnce( | |
| 233 | () => | |
| 234 | new Promise((resolve) => { | |
| 235 | finishLookup = () => resolve(AsyncResult.success({ entries: [] })); | |
| 236 | }), | |
| 237 | ); | |
| 238 | state.choose.mockResolvedValue("reveal"); | |
| 239 | await renderMarkdown("[Readme](readme.md)"); | |
| 240 | await openContextMenu(); | |
| 241 | expect(state.search).toHaveBeenCalledOnce(); | |
| 242 | await act(async () => { | |
| 243 | state.allowed = false; | |
| 244 | finishLookup(); | |
| 245 | }); | |
| 246 | expect(state.openEditor).not.toHaveBeenCalled(); | |
| 247 | }); | |
| 248 | ||
| 249 | it.each([false, true])( | |
| 250 | "keeps PR links usable without offering metadata edits (linked=%s)", | |
| 251 | async (linked) => { | |
| 252 | state.allowed = false; | |
| 253 | state.linkedPullRequest = linked ? linkedPullRequest : null; | |
| 254 | state.choose.mockResolvedValue("open-external"); | |
| 255 | await renderMarkdown(`[Review](${linkedPullRequest.url})`); | |
| 256 | await openContextMenu(); | |
| 257 | expect(offeredActions()).toEqual(["open-external", "copy-link"]); | |
| 258 | expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url); | |
| 259 | expect(state.updateMetadata).not.toHaveBeenCalled(); | |
| 260 | }, | |
| 261 | ); | |
| 262 | ||
| 263 | it.each(["link-to-thread", "unlink-from-thread"])( | |
| 264 | "rechecks %s after a native context menu returns", | |
| 265 | async (action) => { | |
| 266 | state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null; | |
| 267 | let choose: (action: string) => void = () => { | |
| 268 | throw new Error("Menu not opened"); | |
| 269 | }; | |
| 270 | state.choose.mockImplementationOnce( | |
| 271 | () => | |
| 272 | new Promise<string>((resolve) => { | |
| 273 | choose = resolve; | |
| 274 | }), | |
| 275 | ); | |
| 276 | await renderMarkdown(`[Review](${linkedPullRequest.url})`); | |
| 277 | await openContextMenu(); | |
| 278 | expect(offeredActions()).toContain(action); | |
| 279 | await act(async () => { | |
| 280 | state.allowed = false; | |
| 281 | choose(action); | |
| 282 | }); | |
| 283 | expect(state.updateMetadata).not.toHaveBeenCalled(); | |
| 284 | ||
| 285 | await act(async () => { | |
| 286 | state.allowed = true; | |
| 287 | for (const listener of state.listeners) listener(); | |
| 288 | }); | |
| 289 | state.choose.mockResolvedValue(action); | |
| 290 | await openContextMenu(); | |
| 291 | expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ | |
| 292 | environmentId: threadRef.environmentId, | |
| 293 | input: { | |
| 294 | threadId: threadRef.threadId, | |
| 295 | linkedPullRequest: action === "link-to-thread" ? linkedPullRequest : null, | |
| 296 | }, | |
| 297 | }); | |
| 298 | }, | |
| 299 | ); |
apps/web/src/components/ChatMarkdown.test.tsx+14-5| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | // @vitest-environment jsdom |
| 2 | 2 | |
| 3 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; | |
| 4 | 4 | import { act, type ComponentProps, type ReactNode } from "react"; |
| 5 | 5 | import { renderToStaticMarkup } from "react-dom/server"; |
| 6 | 6 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| ... | ... | @@ -38,10 +38,19 @@ vi.mock("./ui/tooltip", async () => { |
| 38 | 38 | }); |
| 39 | 39 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); |
| 40 | 40 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); |
| 41 | vi.mock("../state/session", async (importOriginal) => ({ | |
| 42 | ...(await importOriginal<typeof import("../state/session")>()), | |
| 43 | usePreparedConnection: () => ({ _tag: "Loading" }), | |
| 44 | })); | |
| 41 | vi.mock("../state/session", async (importOriginal) => { | |
| 42 | const actual = await importOriginal<typeof import("../state/session")>(); | |
| 43 | const { AuthStandardClientScopes } = await import("@t3tools/contracts"); | |
| 44 | const grantedScopes = new Set<AuthEnvironmentScope>(AuthStandardClientScopes); | |
| 45 | const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => | |
| 46 | environmentId !== null && grantedScopes.has(scope); | |
| 47 | return { | |
| 48 | ...actual, | |
| 49 | useEnvironmentScope: hasScope, | |
| 50 | readEnvironmentScope: hasScope, | |
| 51 | usePreparedConnection: () => ({ _tag: "Loading" }), | |
| 52 | }; | |
| 53 | }); | |
| 45 | 54 | vi.mock("../state/entities", () => ({ |
| 46 | 55 | readThreadShell: () => null, |
| 47 | 56 | useProjects: () => [], |
apps/web/src/components/ChatMarkdown.tsx+20-8| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 2 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 4 | import { |
| 4 | 5 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| ... | ... | @@ -171,7 +172,7 @@ import { readThreadShell, useProjects } from "../state/entities"; |
| 171 | 172 | import { serverEnvironment } from "../state/server"; |
| 172 | 173 | import { shellEnvironment } from "../state/shell"; |
| 173 | 174 | import { assetEnvironment } from "../state/assets"; |
| 174 | import { usePreparedConnection } from "../state/session"; | |
| 175 | import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session"; | |
| 175 | 176 | import { previewEnvironment } from "../state/preview"; |
| 176 | 177 | import { useAtomCommand } from "../state/use-atom-command"; |
| 177 | 178 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | ... | @@ -2516,12 +2517,11 @@ function useChatMarkdownState({ |
| 2516 | 2517 | }); |
| 2517 | 2518 | const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId); |
| 2518 | 2519 | const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; |
| 2520 | const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 2519 | 2521 | const remoteOpen = useRemoteOpenResolution(environmentId); |
| 2520 | const canUseShellActions = canUseMarkdownFileShellActions( | |
| 2521 | environmentId, | |
| 2522 | remoteOpen.state.mode, | |
| 2523 | remoteOpen.isResolved, | |
| 2524 | ); | |
| 2522 | const canUseShellActions = | |
| 2523 | canOperateHost && | |
| 2524 | canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved); | |
| 2525 | 2525 | const preparedConnection = usePreparedConnection(environmentId); |
| 2526 | 2526 | const openMarkdownMedia = useCallback( |
| 2527 | 2527 | (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { |
| ... | ... | @@ -2591,6 +2591,13 @@ function useChatMarkdownState({ |
| 2591 | 2591 | ), |
| 2592 | 2592 | ); |
| 2593 | 2593 | } |
| 2594 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { | |
| 2595 | return Promise.resolve( | |
| 2596 | AsyncResult.failure<void, Error>( | |
| 2597 | Cause.fail(new Error("This connection cannot reveal files on this environment.")), | |
| 2598 | ), | |
| 2599 | ); | |
| 2600 | } | |
| 2594 | 2601 | return openInEditor({ |
| 2595 | 2602 | environmentId, |
| 2596 | 2603 | input: { cwd: filePath, editor: "file-manager", reveal: true }, |
| ... | ... | @@ -2687,6 +2694,7 @@ function useChatMarkdownState({ |
| 2687 | 2694 | const updateThreadPullRequestLink = useCallback( |
| 2688 | 2695 | async (href: string, linked: boolean) => { |
| 2689 | 2696 | if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; |
| 2697 | if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; | |
| 2690 | 2698 | await pullRequestLinking.changeLink(threadRef, href, linked); |
| 2691 | 2699 | }, |
| 2692 | 2700 | [linkedThreadPullRequestFor, pullRequestLinking, threadRef], |
| ... | ... | @@ -2878,6 +2886,7 @@ function useChatMarkdownState({ |
| 2878 | 2886 | |
| 2879 | 2887 | const componentState = useMemo( |
| 2880 | 2888 | () => ({ |
| 2889 | canOperateHost, | |
| 2881 | 2890 | cwd, |
| 2882 | 2891 | diffThemeName, |
| 2883 | 2892 | environmentId, |
| ... | ... | @@ -2909,6 +2918,7 @@ function useChatMarkdownState({ |
| 2909 | 2918 | updateThreadPullRequestLink, |
| 2910 | 2919 | }), |
| 2911 | 2920 | [ |
| 2921 | canOperateHost, | |
| 2912 | 2922 | cwd, |
| 2913 | 2923 | diffThemeName, |
| 2914 | 2924 | environmentId, |
| ... | ... | @@ -3063,6 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3063 | 3073 | }, |
| 3064 | 3074 | a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { |
| 3065 | 3075 | const { |
| 3076 | canOperateHost, | |
| 3066 | 3077 | cwd, |
| 3067 | 3078 | environmentId, |
| 3068 | 3079 | imageBaseDir, |
| ... | ... | @@ -3203,8 +3214,9 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3203 | 3214 | event.stopPropagation(); |
| 3204 | 3215 | const api = readLocalApi(); |
| 3205 | 3216 | if (!api) return; |
| 3206 | const threadLinkAction = | |
| 3207 | linkedThreadPullRequestFor(href) !== null | |
| 3217 | const threadLinkAction = !canOperateHost | |
| 3218 | ? undefined | |
| 3219 | : linkedThreadPullRequestFor(href) !== null | |
| 3208 | 3220 | ? "unlink-from-thread" |
| 3209 | 3221 | : resolveThreadPullRequest(href) === null |
| 3210 | 3222 | ? undefined |
apps/web/src/components/ChatView.tsx+161-52| ... | ... | @@ -48,12 +48,15 @@ import { |
| 48 | 48 | } from "../questionAttachments"; |
| 49 | 49 | import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; |
| 50 | 50 | import { |
| 51 | AuthOrchestrationOperateScope, | |
| 52 | AuthSettingsWriteScope, | |
| 51 | 53 | type AssistantCitation, |
| 52 | 54 | type ChatFileAttachment, |
| 53 | 55 | CommandId, |
| 54 | 56 | DEFAULT_MODEL, |
| 55 | 57 | isProviderNativeSubagentThread, |
| 56 | 58 | type ChatAttachment as ContractChatAttachment, |
| 59 | EnvironmentAuthorizationError, | |
| 57 | 60 | type EnvironmentId, |
| 58 | 61 | type MessageId, |
| 59 | 62 | type ModelSelection, |
| ... | ... | @@ -285,9 +288,10 @@ import { DeviceSetup } from "./device/DeviceSetup"; |
| 285 | 288 | import { Dialog } from "./ui/dialog"; |
| 286 | 289 | import { WizardPopup } from "./ui/wizard"; |
| 287 | 290 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; |
| 288 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | |
| 289 | 291 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; |
| 290 | 292 | import { isEditableFocused } from "../lib/editableFocus"; |
| 293 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 294 | import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | |
| 291 | 295 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 292 | 296 | import { |
| 293 | 297 | AlarmClockIcon, |
| ... | ... | @@ -555,8 +559,13 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at |
| 555 | 559 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 556 | 560 | import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; |
| 557 | 561 | import { assetEnvironment } from "../state/assets"; |
| 558 | import { readPreparedConnection } from "../state/session"; | |
| 562 | import { | |
| 563 | readEnvironmentScope, | |
| 564 | readPreparedConnection, | |
| 565 | useEnvironmentScope, | |
| 566 | } from "../state/session"; | |
| 559 | 567 | import { useAtomCommand } from "../state/use-atom-command"; |
| 568 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | |
| 560 | 569 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 561 | 570 | import { Button, InlineButton } from "./ui/button"; |
| 562 | 571 | import { |
| ... | ... | @@ -1535,6 +1544,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1535 | 1544 | reserveTitleBarControlInset = true, |
| 1536 | 1545 | forceExpandedMobileComposer = false, |
| 1537 | 1546 | } = props; |
| 1547 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 1538 | 1548 | const draftId = routeKind === "draft" ? props.draftId : null; |
| 1539 | 1549 | const handleNewThread = useNewThreadHandler(); |
| 1540 | 1550 | const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); |
| ... | ... | @@ -1562,46 +1572,48 @@ export default function ChatView(props: ChatViewProps) { |
| 1562 | 1572 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| 1563 | 1573 | const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); |
| 1564 | 1574 | const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); |
| 1565 | const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); | |
| 1566 | const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); | |
| 1567 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 1575 | const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false }); | |
| 1576 | const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false }); | |
| 1577 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | |
| 1568 | 1578 | reportFailure: false, |
| 1569 | 1579 | }); |
| 1570 | 1580 | const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); |
| 1571 | const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { | |
| 1581 | const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { | |
| 1582 | reportFailure: false, | |
| 1583 | }); | |
| 1584 | const setThreadInteractionMode = useOrchestrationCommand(threadEnvironment.setInteractionMode, { | |
| 1572 | 1585 | reportFailure: false, |
| 1573 | 1586 | }); |
| 1574 | const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { | |
| 1587 | const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { | |
| 1575 | 1588 | reportFailure: false, |
| 1576 | 1589 | }); |
| 1577 | const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); | |
| 1578 | const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, { | |
| 1590 | const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, { | |
| 1579 | 1591 | reportFailure: false, |
| 1580 | 1592 | }); |
| 1581 | const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { | |
| 1593 | const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { | |
| 1582 | 1594 | reportFailure: false, |
| 1583 | 1595 | }); |
| 1584 | 1596 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1585 | 1597 | reportFailure: false, |
| 1586 | 1598 | refresh: true, |
| 1587 | 1599 | }); |
| 1588 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { | |
| 1600 | const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, { | |
| 1589 | 1601 | reportFailure: false, |
| 1590 | 1602 | }); |
| 1591 | 1603 | const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, { |
| 1592 | 1604 | label: "load earlier thread history", |
| 1593 | 1605 | reportFailure: false, |
| 1594 | 1606 | }); |
| 1595 | const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { | |
| 1607 | const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, { | |
| 1596 | 1608 | reportFailure: false, |
| 1597 | 1609 | }); |
| 1598 | const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { | |
| 1610 | const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, { | |
| 1599 | 1611 | reportFailure: false, |
| 1600 | 1612 | }); |
| 1601 | 1613 | const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, { |
| 1602 | 1614 | reportFailure: false, |
| 1603 | 1615 | }); |
| 1604 | const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { | |
| 1616 | const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { | |
| 1605 | 1617 | reportFailure: false, |
| 1606 | 1618 | }); |
| 1607 | 1619 | const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, { |
| ... | ... | @@ -3228,6 +3240,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3228 | 3240 | providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ?? |
| 3229 | 3241 | activeProviderStatus; |
| 3230 | 3242 | const supportsConversationRollback = |
| 3243 | canOperateThread && | |
| 3231 | 3244 | conversationProviderStatus !== null && |
| 3232 | 3245 | conversationProviderStatus.supportsConversationRollback !== false; |
| 3233 | 3246 | const phase = derivePhase(activeRuntime); |
| ... | ... | @@ -4065,6 +4078,9 @@ export default function ChatView(props: ChatViewProps) { |
| 4065 | 4078 | resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`, |
| 4066 | 4079 | }); |
| 4067 | 4080 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 4081 | const scriptKeybindings = | |
| 4082 | useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? | |
| 4083 | DEFAULT_RESOLVED_KEYBINDINGS; | |
| 4068 | 4084 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); |
| 4069 | 4085 | const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); |
| 4070 | 4086 | const showOpenInPicker = shouldShowOpenInPicker({ |
| ... | ... | @@ -4497,12 +4513,14 @@ export default function ChatView(props: ChatViewProps) { |
| 4497 | 4513 | const focusComposer = useCallback(() => { |
| 4498 | 4514 | composerRef.current?.focusAtEnd(); |
| 4499 | 4515 | }, [composerRef]); |
| 4500 | const canInterruptRunningThread = deriveCanInterruptRunningThread( | |
| 4501 | activeThread !== undefined, | |
| 4502 | activeRuntime, | |
| 4503 | ); | |
| 4516 | const canInterruptRunningThread = | |
| 4517 | canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); | |
| 4504 | 4518 | const onInterrupt = useCallback(async () => { |
| 4505 | if (!activeThread) return; | |
| 4519 | if ( | |
| 4520 | !activeThread || | |
| 4521 | !readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope) | |
| 4522 | ) | |
| 4523 | return; | |
| 4506 | 4524 | const result = await interruptThreadTurn({ |
| 4507 | 4525 | environmentId, |
| 4508 | 4526 | input: { threadId: activeThread.id }, |
| ... | ... | @@ -4997,9 +5015,37 @@ export default function ChatView(props: ChatViewProps) { |
| 4997 | 5015 | projectCwd: string; |
| 4998 | 5016 | previousScripts: ReadonlyArray<ProjectScript>; |
| 4999 | 5017 | nextScripts: ReadonlyArray<ProjectScript>; |
| 5000 | keybinding?: string | null; | |
| 5018 | keybinding: NewProjectScriptInput["keybinding"]; | |
| 5001 | 5019 | keybindingCommand: KeybindingCommand | null; |
| 5002 | 5020 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 5021 | const previousKeybinding = keybindingValueForCommand( | |
| 5022 | appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [], | |
| 5023 | input.keybindingCommand, | |
| 5024 | ); | |
| 5025 | const isDeletingScript = !input.nextScripts.some( | |
| 5026 | (script) => commandForProjectScript(script.id) === input.keybindingCommand, | |
| 5027 | ); | |
| 5028 | const changesKeybinding = | |
| 5029 | isElectron && | |
| 5030 | input.keybinding !== undefined && | |
| 5031 | (input.keybinding?.trim() || null) !== previousKeybinding && | |
| 5032 | (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope)); | |
| 5033 | if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { | |
| 5034 | return AsyncResult.failure( | |
| 5035 | Cause.fail( | |
| 5036 | new EnvironmentAuthorizationError({ | |
| 5037 | requiredScope: AuthSettingsWriteScope, | |
| 5038 | message: "This connection cannot change keyboard shortcuts.", | |
| 5039 | }), | |
| 5040 | ), | |
| 5041 | ); | |
| 5042 | } | |
| 5043 | const keybindingRule = changesKeybinding | |
| 5044 | ? decodeProjectScriptKeybindingRule({ | |
| 5045 | keybinding: input.keybinding, | |
| 5046 | command: input.keybindingCommand, | |
| 5047 | }) | |
| 5048 | : null; | |
| 5003 | 5049 | const updateResult = mapAtomCommandResult( |
| 5004 | 5050 | await updateProjectScriptSettings({ |
| 5005 | 5051 | environmentId, |
| ... | ... | @@ -5028,10 +5074,19 @@ export default function ChatView(props: ChatViewProps) { |
| 5028 | 5074 | return updateResult; |
| 5029 | 5075 | } |
| 5030 | 5076 | |
| 5031 | const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 5032 | keybinding: input.keybinding, | |
| 5033 | command: input.keybindingCommand, | |
| 5034 | }); | |
| 5077 | if (!changesKeybinding) return updateResult; | |
| 5078 | if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { | |
| 5079 | return AsyncResult.failure( | |
| 5080 | Cause.fail( | |
| 5081 | new EnvironmentAuthorizationError({ | |
| 5082 | requiredScope: AuthSettingsWriteScope, | |
| 5083 | message: isDeletingScript | |
| 5084 | ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed." | |
| 5085 | : "The script was saved, but this connection can no longer change keyboard shortcuts.", | |
| 5086 | }), | |
| 5087 | ), | |
| 5088 | ); | |
| 5089 | } | |
| 5035 | 5090 | |
| 5036 | 5091 | if (!isElectron) return updateResult; |
| 5037 | 5092 | |
| ... | ... | @@ -6991,7 +7046,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6991 | 7046 | ]); |
| 6992 | 7047 | const activeThreadSettled = |
| 6993 | 7048 | supportsSettlement && activeThreadShell?.settledOverride === "settled"; |
| 6994 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | |
| 7049 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { | |
| 6995 | 7050 | reportFailure: false, |
| 6996 | 7051 | }); |
| 6997 | 7052 | // Keyed by thread, not a boolean: the pending state must follow the thread |
| ... | ... | @@ -7000,7 +7055,11 @@ export default function ChatView(props: ChatViewProps) { |
| 7000 | 7055 | const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null); |
| 7001 | 7056 | const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; |
| 7002 | 7057 | const handleUnsettleActiveThread = useCallback(async () => { |
| 7003 | if (!activeThreadRef) return; | |
| 7058 | if ( | |
| 7059 | !activeThreadRef || | |
| 7060 | !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) | |
| 7061 | ) | |
| 7062 | return; | |
| 7004 | 7063 | const threadKey = scopedThreadKey(activeThreadRef); |
| 7005 | 7064 | setUnsettlingThreadKey(threadKey); |
| 7006 | 7065 | try { |
| ... | ... | @@ -7022,13 +7081,17 @@ export default function ChatView(props: ChatViewProps) { |
| 7022 | 7081 | setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); |
| 7023 | 7082 | } |
| 7024 | 7083 | }, [activeThreadRef, unsettleThreadMutation]); |
| 7025 | const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { | |
| 7084 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { | |
| 7026 | 7085 | reportFailure: false, |
| 7027 | 7086 | }); |
| 7028 | 7087 | const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null); |
| 7029 | 7088 | const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; |
| 7030 | 7089 | const handleUnsnoozeActiveThread = useCallback(async () => { |
| 7031 | if (!activeThreadRef) return; | |
| 7090 | if ( | |
| 7091 | !activeThreadRef || | |
| 7092 | !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) | |
| 7093 | ) | |
| 7094 | return; | |
| 7032 | 7095 | const threadKey = scopedThreadKey(activeThreadRef); |
| 7033 | 7096 | setUnsnoozingThreadKey(threadKey); |
| 7034 | 7097 | try { |
| ... | ... | @@ -7175,7 +7238,8 @@ export default function ChatView(props: ChatViewProps) { |
| 7175 | 7238 | const isStoppingBackgroundWork = |
| 7176 | 7239 | stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; |
| 7177 | 7240 | const handleStopBackgroundWork = useCallback(async () => { |
| 7178 | if (!activeThread) return; | |
| 7241 | if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) | |
| 7242 | return; | |
| 7179 | 7243 | const requestKey = `${environmentId}:${activeThread.id}`; |
| 7180 | 7244 | setStoppingBackgroundWorkKey(requestKey); |
| 7181 | 7245 | const result = await interruptThreadTurn({ |
| ... | ... | @@ -7377,7 +7441,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7377 | 7441 | <Button |
| 7378 | 7442 | size="xs" |
| 7379 | 7443 | variant="ghost" |
| 7380 | disabled={isStoppingBackgroundWork} | |
| 7444 | disabled={!canOperateThread || isStoppingBackgroundWork} | |
| 7381 | 7445 | onClick={() => void handleStopBackgroundWork()} |
| 7382 | 7446 | > |
| 7383 | 7447 | {isStoppingBackgroundWork ? "Stopping..." : "Stop"} |
| ... | ... | @@ -7387,6 +7451,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7387 | 7451 | }, [ |
| 7388 | 7452 | activeBackgroundTasks, |
| 7389 | 7453 | activeThread, |
| 7454 | canOperateThread, | |
| 7390 | 7455 | handleStopBackgroundWork, |
| 7391 | 7456 | isStoppingBackgroundWork, |
| 7392 | 7457 | onOpenRelatedThread, |
| ... | ... | @@ -7423,7 +7488,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7423 | 7488 | <Button |
| 7424 | 7489 | size="xs" |
| 7425 | 7490 | variant="ghost" |
| 7426 | disabled={isSnoozed ? isUnsnoozing : isUnsettling} | |
| 7491 | disabled={!canOperateThread || (isSnoozed ? isUnsnoozing : isUnsettling)} | |
| 7427 | 7492 | onClick={() => |
| 7428 | 7493 | void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread()) |
| 7429 | 7494 | } |
| ... | ... | @@ -7442,6 +7507,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7442 | 7507 | activeThread?.id, |
| 7443 | 7508 | activeThreadSettled, |
| 7444 | 7509 | activeThreadSnoozed, |
| 7510 | canOperateThread, | |
| 7445 | 7511 | handleUnsnoozeActiveThread, |
| 7446 | 7512 | handleUnsettleActiveThread, |
| 7447 | 7513 | isUnsnoozing, |
| ... | ... | @@ -7463,6 +7529,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7463 | 7529 | (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), |
| 7464 | 7530 | ); |
| 7465 | 7531 | const compactThreadUnavailable = |
| 7532 | !canOperateThread || | |
| 7466 | 7533 | !activeThread || |
| 7467 | 7534 | !activeThreadHasCompactableConversation || |
| 7468 | 7535 | !activeProject || |
| ... | ... | @@ -7479,11 +7546,13 @@ export default function ChatView(props: ChatViewProps) { |
| 7479 | 7546 | showPlanFollowUpPrompt; |
| 7480 | 7547 | const compactDisabled = compactThreadUnavailable; |
| 7481 | 7548 | const compactDisabledReason = compactDisabled |
| 7482 | ? !activeProject | |
| 7483 | ? "Choose a project before compacting" | |
| 7484 | : !manualCompactionProviderAvailable | |
| 7485 | ? "Compaction is unavailable for this provider" | |
| 7486 | : "Compacting is unavailable right now" | |
| 7549 | ? !canOperateThread | |
| 7550 | ? "This connection cannot change threads." | |
| 7551 | : !activeProject | |
| 7552 | ? "Choose a project before compacting" | |
| 7553 | : !manualCompactionProviderAvailable | |
| 7554 | ? "Compaction is unavailable for this provider" | |
| 7555 | : "Compacting is unavailable right now" | |
| 7487 | 7556 | : null; |
| 7488 | 7557 | const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 7489 | 7558 | if ( |
| ... | ... | @@ -7813,7 +7882,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7813 | 7882 | } |
| 7814 | 7883 | } |
| 7815 | 7884 | |
| 7816 | const command = resolveShortcutCommand(event, keybindings, { | |
| 7885 | const command = resolveChatShortcutCommand(event, keybindings, scriptKeybindings, { | |
| 7817 | 7886 | context: shortcutContext, |
| 7818 | 7887 | }); |
| 7819 | 7888 | if (!command) return; |
| ... | ... | @@ -7826,6 +7895,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7826 | 7895 | } |
| 7827 | 7896 | |
| 7828 | 7897 | if (command === "thread.settle") { |
| 7898 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 7829 | 7899 | event.preventDefault(); |
| 7830 | 7900 | event.stopPropagation(); |
| 7831 | 7901 | if (!isServerThread || !activeThreadRef || !supportsSettlement) return; |
| ... | ... | @@ -7849,6 +7919,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7849 | 7919 | } |
| 7850 | 7920 | |
| 7851 | 7921 | if (command === "thread.pin") { |
| 7922 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 7852 | 7923 | event.preventDefault(); |
| 7853 | 7924 | event.stopPropagation(); |
| 7854 | 7925 | if (!isServerThread || !activeThreadRef || !supportsPinning) return; |
| ... | ... | @@ -8069,6 +8140,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8069 | 8140 | splitTerminal, |
| 8070 | 8141 | splitPanelTerminal, |
| 8071 | 8142 | keybindings, |
| 8143 | scriptKeybindings, | |
| 8072 | 8144 | handleUnsettleActiveThread, |
| 8073 | 8145 | isServerThread, |
| 8074 | 8146 | onInterrupt, |
| ... | ... | @@ -8143,7 +8215,13 @@ export default function ChatView(props: ChatViewProps) { |
| 8143 | 8215 | const onRevertToTurnCount = useCallback( |
| 8144 | 8216 | async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => { |
| 8145 | 8217 | const localApi = readLocalApi(); |
| 8146 | if (!localApi || !activeThread || isRevertingCheckpoint) return; | |
| 8218 | if ( | |
| 8219 | !localApi || | |
| 8220 | !activeThread || | |
| 8221 | isRevertingCheckpoint || | |
| 8222 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) | |
| 8223 | ) | |
| 8224 | return; | |
| 8147 | 8225 | const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId); |
| 8148 | 8226 | const message = sourceMessage |
| 8149 | 8227 | ? { |
| ... | ... | @@ -8514,6 +8592,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8514 | 8592 | ); |
| 8515 | 8593 | }; |
| 8516 | 8594 | if ( |
| 8595 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 8517 | 8596 | !activeThread || |
| 8518 | 8597 | isSendBusy || |
| 8519 | 8598 | isConnecting || |
| ... | ... | @@ -9141,6 +9220,23 @@ export default function ChatView(props: ChatViewProps) { |
| 9141 | 9220 | void dockTransition.catch(() => resolveDockStarted?.()); |
| 9142 | 9221 | await dockStarted; |
| 9143 | 9222 | } |
| 9223 | ||
| 9224 | const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities(); | |
| 9225 | if ( | |
| 9226 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 9227 | attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null | |
| 9228 | ) { | |
| 9229 | sendInFlightRef.current = false; | |
| 9230 | setThreadError( | |
| 9231 | threadIdForSend, | |
| 9232 | attachmentCapabilitiesBeforeDispatch.fileBlockReason ?? | |
| 9233 | "This connection cannot change threads.", | |
| 9234 | ); | |
| 9235 | setDockedDraftHeroThreadKey((currentThreadKey) => | |
| 9236 | currentThreadKey === activeThreadKey ? null : currentThreadKey, | |
| 9237 | ); | |
| 9238 | return; | |
| 9239 | } | |
| 9144 | 9240 | beginLocalDispatch({ |
| 9145 | 9241 | preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), |
| 9146 | 9242 | // Only a draft has a background submission to hide behind its hero. |
| ... | ... | @@ -9815,7 +9911,8 @@ export default function ChatView(props: ChatViewProps) { |
| 9815 | 9911 | |
| 9816 | 9912 | const onRespondToApproval = useCallback( |
| 9817 | 9913 | async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { |
| 9818 | if (!activeThreadId) return; | |
| 9914 | if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) | |
| 9915 | return; | |
| 9819 | 9916 | if ( |
| 9820 | 9917 | pendingApprovals.find((approval) => approval.requestId === requestId) |
| 9821 | 9918 | ?.responseCapability !== "live" |
| ... | ... | @@ -9848,7 +9945,8 @@ export default function ChatView(props: ChatViewProps) { |
| 9848 | 9945 | |
| 9849 | 9946 | const onRespondToUserInput = useCallback( |
| 9850 | 9947 | async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => { |
| 9851 | if (!activeThreadId) return; | |
| 9948 | if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) | |
| 9949 | return; | |
| 9852 | 9950 | const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); |
| 9853 | 9951 | if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; |
| 9854 | 9952 | const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); |
| ... | ... | @@ -10079,7 +10177,14 @@ export default function ChatView(props: ChatViewProps) { |
| 10079 | 10177 | context?: ReturnType<typeof buildMessageContext>; |
| 10080 | 10178 | interactionMode: "default" | "plan"; |
| 10081 | 10179 | }) { |
| 10082 | if (!activeThread || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current) { | |
| 10180 | if ( | |
| 10181 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 10182 | !activeThread || | |
| 10183 | !isServerThread || | |
| 10184 | isSendBusy || | |
| 10185 | isConnecting || | |
| 10186 | sendInFlightRef.current | |
| 10187 | ) { | |
| 10083 | 10188 | return false; |
| 10084 | 10189 | } |
| 10085 | 10190 | |
| ... | ... | @@ -10213,6 +10318,7 @@ export default function ChatView(props: ChatViewProps) { |
| 10213 | 10318 | |
| 10214 | 10319 | const onImplementPlanInNewThread = useCallback(async () => { |
| 10215 | 10320 | if ( |
| 10321 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 10216 | 10322 | !activeThread || |
| 10217 | 10323 | !activeProject || |
| 10218 | 10324 | !activeProposedPlan || |
| ... | ... | @@ -11264,6 +11370,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11264 | 11370 | ) : null} |
| 11265 | 11371 | {!composerMounted ? null : ( |
| 11266 | 11372 | <ChatComposer |
| 11373 | canOperateThread={canOperateThread} | |
| 11267 | 11374 | reportedModelSelection={reportedModelSelection} |
| 11268 | 11375 | multipleModelSelections={multipleModelSelections} |
| 11269 | 11376 | supportsMultipleModels={ |
| ... | ... | @@ -11301,17 +11408,19 @@ export default function ChatView(props: ChatViewProps) { |
| 11301 | 11408 | canResume={resumableRunId !== null || hasHeldQueuedRuns} |
| 11302 | 11409 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 11303 | 11410 | sendDisabledReason={ |
| 11304 | isEnvironmentChanging | |
| 11305 | ? "Preparing machine" | |
| 11306 | : isRevertingCheckpoint | |
| 11307 | ? "Rewinding conversation" | |
| 11308 | : feedbackUploading | |
| 11309 | ? "Sending feedback" | |
| 11310 | : threadDetailLoading | |
| 11311 | ? "Messages loading" | |
| 11312 | : worktreeSetupBlocksSend | |
| 11313 | ? "Preparing worktree" | |
| 11314 | : projectCloneSendBlockReason | |
| 11411 | !canOperateThread | |
| 11412 | ? "This connection cannot change threads." | |
| 11413 | : isEnvironmentChanging | |
| 11414 | ? "Preparing machine" | |
| 11415 | : isRevertingCheckpoint | |
| 11416 | ? "Rewinding conversation" | |
| 11417 | : feedbackUploading | |
| 11418 | ? "Sending feedback" | |
| 11419 | : threadDetailLoading | |
| 11420 | ? "Messages loading" | |
| 11421 | : worktreeSetupBlocksSend | |
| 11422 | ? "Preparing worktree" | |
| 11423 | : projectCloneSendBlockReason | |
| 11315 | 11424 | } |
| 11316 | 11425 | isPreparingWorktree={isPreparingWorktree} |
| 11317 | 11426 | queuedRunsControl={ |
apps/web/src/components/KeybindingsConfigWarning.test.tsx created+119| ... | ... | @@ -0,0 +1,119 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import { | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | EnvironmentId, | |
| 5 | type AuthEnvironmentScope, | |
| 6 | type AuthSessionState, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 9 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 10 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>; | |
| 13 | const state = vi.hoisted(() => ({ | |
| 14 | registry: null as AtomRegistry.AtomRegistry | null, | |
| 15 | sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(), | |
| 16 | run: vi.fn(), | |
| 17 | toast: vi.fn(), | |
| 18 | })); | |
| 19 | ||
| 20 | vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined })); | |
| 21 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | |
| 22 | createEnvironmentSessionAtoms: () => ({ | |
| 23 | sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, | |
| 24 | }), | |
| 25 | })); | |
| 26 | vi.mock("../rpc/atomRegistry", () => ({ | |
| 27 | get appAtomRegistry() { | |
| 28 | return state.registry; | |
| 29 | }, | |
| 30 | })); | |
| 31 | vi.mock("../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } })); | |
| 32 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.run })); | |
| 33 | vi.mock("../hooks/useLocalStorage", () => ({ | |
| 34 | getLocalStorageItem: () => "vscode", | |
| 35 | setLocalStorageItem: vi.fn(), | |
| 36 | useLocalStorage: vi.fn(), | |
| 37 | })); | |
| 38 | vi.mock("./ui/button", () => ({ Button: "button" })); | |
| 39 | vi.mock("./ui/toast", () => ({ | |
| 40 | toastManager: { add: state.toast }, | |
| 41 | stackedThreadToast: (value: unknown) => value, | |
| 42 | })); | |
| 43 | ||
| 44 | import { KeybindingsConfigWarning } from "./KeybindingsConfigWarning"; | |
| 45 | ||
| 46 | const environmentId = EnvironmentId.make("warning-environment"); | |
| 47 | let renderer: ReactTestRenderer | undefined; | |
| 48 | const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({ | |
| 49 | authenticated: true, | |
| 50 | scopes, | |
| 51 | auth: { | |
| 52 | policy: "remote-reachable", | |
| 53 | bootstrapMethods: ["one-time-token"], | |
| 54 | sessionMethods: ["bearer-access-token"], | |
| 55 | sessionCookieName: "t3_session", | |
| 56 | }, | |
| 57 | }); | |
| 58 | ||
| 59 | beforeEach(() => { | |
| 60 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 61 | state.registry = AtomRegistry.make(); | |
| 62 | state.sessions.set( | |
| 63 | environmentId, | |
| 64 | Atom.make<SessionResult>(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe( | |
| 65 | Atom.keepAlive, | |
| 66 | ), | |
| 67 | ); | |
| 68 | state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 69 | state.toast.mockReset(); | |
| 70 | }); | |
| 71 | ||
| 72 | afterEach(async () => { | |
| 73 | await act(async () => renderer?.unmount()); | |
| 74 | renderer = undefined; | |
| 75 | state.registry?.dispose(); | |
| 76 | state.sessions.clear(); | |
| 77 | vi.unstubAllGlobals(); | |
| 78 | }); | |
| 79 | ||
| 80 | it("updates a visible warning on revocation and blocks its retained action until regrant", async () => { | |
| 81 | await act(async () => { | |
| 82 | renderer = create( | |
| 83 | <RegistryContext.Provider value={state.registry!}> | |
| 84 | <KeybindingsConfigWarning | |
| 85 | environmentId={environmentId} | |
| 86 | configPath="/t3/keybindings.json" | |
| 87 | availableEditors={["vscode"]} | |
| 88 | message="Invalid shortcut" | |
| 89 | /> | |
| 90 | </RegistryContext.Provider>, | |
| 91 | ); | |
| 92 | }); | |
| 93 | const open = renderer!.root.findByType("button").props.onClick; | |
| 94 | expect(renderer!.root.findByType("button").props.disabled).toBe(false); | |
| 95 | await act(async () => { | |
| 96 | state.registry!.set(state.sessions.get(environmentId)!, AsyncResult.success(session([]))); | |
| 97 | await open(); | |
| 98 | }); | |
| 99 | expect(renderer!.root.findByType("button").props.disabled).toBe(true); | |
| 100 | expect(state.run).not.toHaveBeenCalled(); | |
| 101 | expect(state.toast).toHaveBeenCalledWith( | |
| 102 | expect.objectContaining({ | |
| 103 | title: "Unable to open keybindings file", | |
| 104 | }), | |
| 105 | ); | |
| 106 | ||
| 107 | await act(async () => { | |
| 108 | state.registry!.set( | |
| 109 | state.sessions.get(environmentId)!, | |
| 110 | AsyncResult.success(session([AuthOrchestrationOperateScope])), | |
| 111 | ); | |
| 112 | }); | |
| 113 | expect(renderer!.root.findByType("button").props.disabled).toBe(false); | |
| 114 | await act(async () => open()); | |
| 115 | expect(state.run).toHaveBeenCalledExactlyOnceWith({ | |
| 116 | environmentId, | |
| 117 | input: { cwd: "/t3/keybindings.json", editor: "vscode" }, | |
| 118 | }); | |
| 119 | }); |
apps/web/src/components/KeybindingsConfigWarning.tsx created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | type EditorId, | |
| 4 | type EnvironmentId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 7 | ||
| 8 | import { useOpenInPreferredEditor } from "../editorPreferences"; | |
| 9 | import { useEnvironmentScope } from "../state/session"; | |
| 10 | import { Button } from "./ui/button"; | |
| 11 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 12 | ||
| 13 | export function KeybindingsConfigWarning({ | |
| 14 | message, | |
| 15 | environmentId, | |
| 16 | configPath, | |
| 17 | availableEditors, | |
| 18 | }: { | |
| 19 | message: string; | |
| 20 | environmentId: EnvironmentId | null; | |
| 21 | configPath: string | null; | |
| 22 | availableEditors: readonly EditorId[]; | |
| 23 | }) { | |
| 24 | const canOpenEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 25 | const openInEditor = useOpenInPreferredEditor(environmentId, availableEditors); | |
| 26 | ||
| 27 | return ( | |
| 28 | <> | |
| 29 | {message} | |
| 30 | <span className="mt-2 flex justify-end"> | |
| 31 | <Button | |
| 32 | size="xs" | |
| 33 | variant="outline" | |
| 34 | disabled={!canOpenEditor || !configPath || availableEditors.length === 0} | |
| 35 | onClick={async () => { | |
| 36 | if (!configPath) return; | |
| 37 | const result = await openInEditor(configPath); | |
| 38 | if (result._tag === "Success") return; | |
| 39 | const error = squashAtomCommandFailure(result); | |
| 40 | toastManager.add( | |
| 41 | stackedThreadToast({ | |
| 42 | type: "error", | |
| 43 | title: "Unable to open keybindings file", | |
| 44 | description: error instanceof Error ? error.message : "Unknown error opening file.", | |
| 45 | }), | |
| 46 | ); | |
| 47 | }} | |
| 48 | > | |
| 49 | Open keybindings.json | |
| 50 | </Button> | |
| 51 | </span> | |
| 52 | </> | |
| 53 | ); | |
| 54 | } |
apps/web/src/components/LegacySidebar.tsx+102-14| ... | ... | @@ -45,7 +45,9 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- |
| 45 | 45 | import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; |
| 46 | 46 | import { CSS } from "@dnd-kit/utilities"; |
| 47 | 47 | import { |
| 48 | AuthOrchestrationOperateScope, | |
| 48 | 49 | type ContextMenuItem, |
| 50 | type EnvironmentId, | |
| 49 | 51 | ProjectId, |
| 50 | 52 | type ScopedThreadRef, |
| 51 | 53 | type ResolvedKeybindingsConfig, |
| ... | ... | @@ -96,7 +98,12 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 96 | 98 | import { useThreadDiscoveredPorts } from "../portDiscoveryState"; |
| 97 | 99 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; |
| 98 | 100 | import { useAtomCommand } from "../state/use-atom-command"; |
| 101 | import { | |
| 102 | ORCHESTRATION_OPERATE_DENIED_MESSAGE, | |
| 103 | useOrchestrationCommand, | |
| 104 | } from "../state/use-orchestration-command"; | |
| 99 | 105 | import { previewEnvironment } from "../state/preview"; |
| 106 | import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; | |
| 100 | 107 | import { |
| 101 | 108 | legacyProjectCwdPreferenceKey, |
| 102 | 109 | resolveProjectExpanded, |
| ... | ... | @@ -357,6 +364,18 @@ interface SidebarThreadRowProps { |
| 357 | 364 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; |
| 358 | 365 | } |
| 359 | 366 | |
| 367 | function checkTaskPermission(environmentId: EnvironmentId): boolean { | |
| 368 | if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true; | |
| 369 | toastManager.add( | |
| 370 | stackedThreadToast({ | |
| 371 | type: "error", | |
| 372 | title: "Action unavailable", | |
| 373 | description: ORCHESTRATION_OPERATE_DENIED_MESSAGE, | |
| 374 | }), | |
| 375 | ); | |
| 376 | return false; | |
| 377 | } | |
| 378 | ||
| 360 | 379 | const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 361 | 380 | const { |
| 362 | 381 | orderedProjectThreadKeys, |
| ... | ... | @@ -385,6 +404,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 385 | 404 | onFileDropThreads, |
| 386 | 405 | thread, |
| 387 | 406 | } = props; |
| 407 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); | |
| 388 | 408 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 389 | 409 | const threadKey = scopedThreadKey(threadRef); |
| 390 | 410 | const [isFileDragOver, setIsFileDragOver] = useState(false); |
| ... | ... | @@ -485,10 +505,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 485 | 505 | : null; |
| 486 | 506 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); |
| 487 | 507 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 488 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; | |
| 508 | const isConfirmingArchive = | |
| 509 | canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; | |
| 489 | 510 | const threadMetaClassName = isConfirmingArchive |
| 490 | 511 | ? "pointer-events-none opacity-0" |
| 491 | : !isThreadRunning | |
| 512 | : canOperateThread && !isThreadRunning | |
| 492 | 513 | ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" |
| 493 | 514 | : "pointer-events-none"; |
| 494 | 515 | const clearConfirmingArchive = useCallback(() => { |
| ... | ... | @@ -517,6 +538,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 517 | 538 | ); |
| 518 | 539 | const handleRowDoubleClick = useCallback( |
| 519 | 540 | (event: React.MouseEvent) => { |
| 541 | if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return; | |
| 520 | 542 | // Already renaming this row: a double-click on the row chrome (outside the |
| 521 | 543 | // input) must not restart and discard the in-progress edit. |
| 522 | 544 | if (renamingThreadKey === threadKey) return; |
| ... | ... | @@ -531,7 +553,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 531 | 553 | event.preventDefault(); |
| 532 | 554 | startThreadRename(threadKey, thread.title); |
| 533 | 555 | }, |
| 534 | [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], | |
| 556 | [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.environmentId, thread.title], | |
| 535 | 557 | ); |
| 536 | 558 | const handleRowKeyDown = useCallback( |
| 537 | 559 | (event: React.KeyboardEvent) => { |
| ... | ... | @@ -685,12 +707,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 685 | 707 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| 686 | 708 | event.preventDefault(); |
| 687 | 709 | event.stopPropagation(); |
| 710 | if (!checkTaskPermission(thread.environmentId)) return; | |
| 688 | 711 | setConfirmingArchiveThreadKey(threadKey); |
| 689 | 712 | requestAnimationFrame(() => { |
| 690 | 713 | confirmArchiveButtonRefs.current.get(threadKey)?.focus(); |
| 691 | 714 | }); |
| 692 | 715 | }, |
| 693 | [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], | |
| 716 | [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey, thread.environmentId], | |
| 694 | 717 | ); |
| 695 | 718 | const handleArchiveImmediateClick = useCallback( |
| 696 | 719 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| ... | ... | @@ -775,7 +798,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 775 | 798 | </a> |
| 776 | 799 | ) : null} |
| 777 | 800 | {threadStatus && <ThreadStatusLabel status={threadStatus} />} |
| 778 | {renamingThreadKey === threadKey ? ( | |
| 801 | {canOperateThread && renamingThreadKey === threadKey ? ( | |
| 779 | 802 | <input |
| 780 | 803 | ref={handleRenameInputRef} |
| 781 | 804 | className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none" |
| ... | ... | @@ -861,7 +884,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 861 | 884 | > |
| 862 | 885 | Confirm |
| 863 | 886 | </button> |
| 864 | ) : !isThreadRunning ? ( | |
| 887 | ) : canOperateThread && !isThreadRunning ? ( | |
| 865 | 888 | appSettingsConfirmThreadArchive ? ( |
| 866 | 889 | <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"> |
| 867 | 890 | <button |
| ... | ... | @@ -1191,13 +1214,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1191 | 1214 | (settings) => settings.confirmThreadArchive, |
| 1192 | 1215 | ); |
| 1193 | 1216 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 1194 | const deleteProject = useAtomCommand(projectEnvironment.delete, { | |
| 1217 | const deleteProject = useOrchestrationCommand(projectEnvironment.delete, { | |
| 1195 | 1218 | reportFailure: false, |
| 1196 | 1219 | }); |
| 1197 | const updateProject = useAtomCommand(projectEnvironment.update, { | |
| 1220 | const updateProject = useOrchestrationCommand(projectEnvironment.update, { | |
| 1198 | 1221 | reportFailure: false, |
| 1199 | 1222 | }); |
| 1200 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 1223 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | |
| 1201 | 1224 | reportFailure: false, |
| 1202 | 1225 | }); |
| 1203 | 1226 | const updateSettings = useUpdateClientSettings(); |
| ... | ... | @@ -1293,6 +1316,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1293 | 1316 | null, |
| 1294 | 1317 | ); |
| 1295 | 1318 | const [projectRenameTitle, setProjectRenameTitle] = useState(""); |
| 1319 | const canRenameProject = useEnvironmentScope( | |
| 1320 | projectRenameTarget?.environmentId ?? null, | |
| 1321 | AuthOrchestrationOperateScope, | |
| 1322 | ); | |
| 1296 | 1323 | const [projectGroupingTarget, setProjectGroupingTarget] = |
| 1297 | 1324 | useState<SidebarProjectGroupMember | null>(null); |
| 1298 | 1325 | const [projectGroupingSelection, setProjectGroupingSelection] = useState< |
| ... | ... | @@ -1501,6 +1528,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1501 | 1528 | ); |
| 1502 | 1529 | |
| 1503 | 1530 | const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { |
| 1531 | if (!checkTaskPermission(member.environmentId)) return; | |
| 1504 | 1532 | setProjectRenameTarget(member); |
| 1505 | 1533 | setProjectRenameTitle(member.title); |
| 1506 | 1534 | }, []); |
| ... | ... | @@ -1551,6 +1579,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1551 | 1579 | |
| 1552 | 1580 | const handleRemoveProject = useCallback( |
| 1553 | 1581 | async (member: SidebarProjectGroupMember) => { |
| 1582 | if (!checkTaskPermission(member.environmentId)) return; | |
| 1554 | 1583 | const api = readLocalApi(); |
| 1555 | 1584 | if (!api) { |
| 1556 | 1585 | return; |
| ... | ... | @@ -1573,6 +1602,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1573 | 1602 | await new Promise<void>((resolve) => { |
| 1574 | 1603 | window.setTimeout(resolve, 180); |
| 1575 | 1604 | }); |
| 1605 | if (!checkTaskPermission(member.environmentId)) return; | |
| 1576 | 1606 | |
| 1577 | 1607 | const latestProjectThreads = Array.from( |
| 1578 | 1608 | sidebarThreadByKeyRef.current.values(), |
| ... | ... | @@ -1765,12 +1795,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1765 | 1795 | |
| 1766 | 1796 | const clicked = await api.contextMenu.show( |
| 1767 | 1797 | [ |
| 1768 | buildTargetedItem("rename", "Rename"), | |
| 1798 | buildTargetedItem("rename", "Rename", { | |
| 1799 | isDisabled: (member) => | |
| 1800 | !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope), | |
| 1801 | }), | |
| 1769 | 1802 | buildTargetedItem("grouping", "Group into..."), |
| 1770 | 1803 | buildTargetedItem("copy-path", "Copy Path"), |
| 1771 | 1804 | { id: "project-settings", label: "Project settings", icon: "settings" }, |
| 1772 | 1805 | buildTargetedItem("delete", "Remove", { |
| 1773 | 1806 | destructive: true, |
| 1807 | isDisabled: (member) => | |
| 1808 | !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope), | |
| 1774 | 1809 | }), |
| 1775 | 1810 | ], |
| 1776 | 1811 | { |
| ... | ... | @@ -1907,9 +1942,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1907 | 1942 | const hasRunningThread = selectedThreadEntries.some( |
| 1908 | 1943 | ({ thread }) => !threadRuntimeCanArchive(thread.runtime), |
| 1909 | 1944 | ); |
| 1945 | const canOperateSelection = selectedThreadEntries.every(({ threadRef }) => | |
| 1946 | readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), | |
| 1947 | ); | |
| 1910 | 1948 | |
| 1911 | 1949 | const clicked = await api.contextMenu.show( |
| 1912 | buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), | |
| 1950 | buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }).map((item) => | |
| 1951 | item.id === "archive" || item.id === "delete" | |
| 1952 | ? { ...item, disabled: item.disabled || !canOperateSelection } | |
| 1953 | : item, | |
| 1954 | ), | |
| 1913 | 1955 | position, |
| 1914 | 1956 | ); |
| 1915 | 1957 | |
| ... | ... | @@ -1921,6 +1963,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1921 | 1963 | return; |
| 1922 | 1964 | } |
| 1923 | 1965 | |
| 1966 | if ( | |
| 1967 | (clicked === "archive" || clicked === "delete") && | |
| 1968 | !selectedThreadEntries.every(({ threadRef }) => | |
| 1969 | checkTaskPermission(threadRef.environmentId), | |
| 1970 | ) | |
| 1971 | ) { | |
| 1972 | return; | |
| 1973 | } | |
| 1974 | ||
| 1924 | 1975 | if (clicked === "archive") { |
| 1925 | 1976 | if (appSettingsConfirmThreadArchive) { |
| 1926 | 1977 | const confirmed = await api.dialogs.confirm( |
| ... | ... | @@ -1928,6 +1979,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1928 | 1979 | ); |
| 1929 | 1980 | if (!confirmed) return; |
| 1930 | 1981 | } |
| 1982 | if ( | |
| 1983 | !selectedThreadEntries.every(({ threadRef }) => | |
| 1984 | checkTaskPermission(threadRef.environmentId), | |
| 1985 | ) | |
| 1986 | ) { | |
| 1987 | return; | |
| 1988 | } | |
| 1931 | 1989 | |
| 1932 | 1990 | const archiveOutcome = await archiveSelectedThreadEntries({ |
| 1933 | 1991 | entries: selectedThreadEntries, |
| ... | ... | @@ -1974,6 +2032,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1974 | 2032 | ); |
| 1975 | 2033 | if (!confirmed) return; |
| 1976 | 2034 | } |
| 2035 | if ( | |
| 2036 | !selectedThreadEntries.every(({ threadRef }) => | |
| 2037 | checkTaskPermission(threadRef.environmentId), | |
| 2038 | ) | |
| 2039 | ) { | |
| 2040 | return; | |
| 2041 | } | |
| 1977 | 2042 | |
| 1978 | 2043 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ |
| 1979 | 2044 | entries: selectedThreadEntries, |
| ... | ... | @@ -2090,6 +2155,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2090 | 2155 | |
| 2091 | 2156 | const attemptArchiveThread = useCallback( |
| 2092 | 2157 | async (threadRef: ScopedThreadRef) => { |
| 2158 | if (!checkTaskPermission(threadRef.environmentId)) return; | |
| 2093 | 2159 | const result = await archiveThread(threadRef); |
| 2094 | 2160 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2095 | 2161 | const error = squashAtomCommandFailure(result); |
| ... | ... | @@ -2111,6 +2177,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2111 | 2177 | }, []); |
| 2112 | 2178 | |
| 2113 | 2179 | const startThreadRename = useCallback((threadKey: string, title: string) => { |
| 2180 | const threadRef = parseScopedThreadKey(threadKey); | |
| 2181 | if (!threadRef || !checkTaskPermission(threadRef.environmentId)) return; | |
| 2114 | 2182 | setRenamingThreadKey(threadKey); |
| 2115 | 2183 | setRenamingTitle(title); |
| 2116 | 2184 | renamingCommittedRef.current = false; |
| ... | ... | @@ -2250,17 +2318,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2250 | 2318 | ); |
| 2251 | 2319 | const threadWorkspacePath = |
| 2252 | 2320 | thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; |
| 2321 | const canOperateThread = readEnvironmentScope( | |
| 2322 | thread.environmentId, | |
| 2323 | AuthOrchestrationOperateScope, | |
| 2324 | ); | |
| 2253 | 2325 | const clicked = await api.contextMenu.show( |
| 2254 | 2326 | [ |
| 2255 | 2327 | ...(thread.branch |
| 2256 | 2328 | ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] |
| 2257 | 2329 | : []), |
| 2258 | { id: "rename", label: "Rename thread" }, | |
| 2330 | { id: "rename", label: "Rename thread", disabled: !canOperateThread }, | |
| 2259 | 2331 | { id: "mark-unread", label: "Mark unread" }, |
| 2260 | 2332 | { id: "copy-path", label: "Copy Path" }, |
| 2261 | 2333 | { id: "copy-thread-id", label: "Copy Thread ID" }, |
| 2262 | 2334 | { id: "project-settings", label: "Project settings" }, |
| 2263 | { id: "delete", label: "Delete", destructive: true, icon: "trash" }, | |
| 2335 | { | |
| 2336 | id: "delete", | |
| 2337 | label: "Delete", | |
| 2338 | destructive: true, | |
| 2339 | icon: "trash", | |
| 2340 | disabled: !canOperateThread, | |
| 2341 | }, | |
| 2264 | 2342 | ], |
| 2265 | 2343 | position, |
| 2266 | 2344 | ); |
| ... | ... | @@ -2326,6 +2404,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2326 | 2404 | return; |
| 2327 | 2405 | } |
| 2328 | 2406 | if (clicked !== "delete") return; |
| 2407 | if (!checkTaskPermission(threadRef.environmentId)) return; | |
| 2329 | 2408 | if (appSettingsConfirmThreadDelete) { |
| 2330 | 2409 | const confirmed = await api.dialogs.confirm( |
| 2331 | 2410 | [ |
| ... | ... | @@ -2338,6 +2417,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2338 | 2417 | return; |
| 2339 | 2418 | } |
| 2340 | 2419 | } |
| 2420 | if (!checkTaskPermission(threadRef.environmentId)) return; | |
| 2341 | 2421 | const result = await deleteThread(threadRef); |
| 2342 | 2422 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2343 | 2423 | const error = squashAtomCommandFailure(result); |
| ... | ... | @@ -2533,6 +2613,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2533 | 2613 | <span className="text-xs font-medium text-foreground">Project title</span> |
| 2534 | 2614 | <Input |
| 2535 | 2615 | aria-label="Project title" |
| 2616 | disabled={!canRenameProject} | |
| 2536 | 2617 | value={projectRenameTitle} |
| 2537 | 2618 | onChange={(event) => setProjectRenameTitle(event.target.value)} |
| 2538 | 2619 | onKeyDown={(event) => { |
| ... | ... | @@ -2548,12 +2629,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2548 | 2629 | Environment: {projectRenameTarget.environmentLabel} |
| 2549 | 2630 | </p> |
| 2550 | 2631 | ) : null} |
| 2632 | {!canRenameProject ? ( | |
| 2633 | <p className="text-xs text-muted-foreground"> | |
| 2634 | This connection cannot change projects. | |
| 2635 | </p> | |
| 2636 | ) : null} | |
| 2551 | 2637 | </DialogPanel> |
| 2552 | 2638 | <DialogFooter> |
| 2553 | 2639 | <Button variant="outline" onClick={closeProjectRenameDialog}> |
| 2554 | 2640 | Cancel |
| 2555 | 2641 | </Button> |
| 2556 | <Button onClick={() => void submitProjectRename()}>Save</Button> | |
| 2642 | <Button disabled={!canRenameProject} onClick={() => void submitProjectRename()}> | |
| 2643 | Save | |
| 2644 | </Button> | |
| 2557 | 2645 | </DialogFooter> |
| 2558 | 2646 | </DialogPopup> |
| 2559 | 2647 | </Dialog> |
apps/web/src/components/ProjectScriptsControl.tsx+17-7| ... | ... | @@ -1,9 +1,13 @@ |
| 1 | 1 | import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; |
| 2 | import type { | |
| 3 | ProjectScript, | |
| 4 | ResolvedKeybindingsConfig, | |
| 5 | T3ProjectFileScript, | |
| 2 | import { | |
| 3 | AuthSettingsWriteScope, | |
| 4 | type EnvironmentId, | |
| 5 | type ProjectScript, | |
| 6 | type T3ProjectFileScript, | |
| 7 | type ResolvedKeybindingsConfig, | |
| 6 | 8 | } from "@t3tools/contracts"; |
| 9 | import { useAtomValue } from "@effect/atom-react"; | |
| 10 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 7 | 11 | import { |
| 8 | 12 | isAtomCommandInterrupted, |
| 9 | 13 | squashAtomCommandFailure, |
| ... | ... | @@ -13,6 +17,8 @@ import React, { useCallback, useMemo, useState } from "react"; |
| 13 | 17 | |
| 14 | 18 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; |
| 15 | 19 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 20 | import { serverEnvironment } from "~/state/server"; | |
| 21 | import { readEnvironmentScope } from "~/state/session"; | |
| 16 | 22 | import { |
| 17 | 23 | EMPTY_PROJECT_SCRIPT_INPUT, |
| 18 | 24 | editorRequestForScript, |
| ... | ... | @@ -55,10 +61,10 @@ interface ProjectScriptsControlProps { |
| 55 | 61 | displayMode?: "toolbar" | "panel"; |
| 56 | 62 | presentation?: "toolbar" | "menu"; |
| 57 | 63 | onRequestMenuClose?: () => void; |
| 64 | environmentId: EnvironmentId; | |
| 58 | 65 | scripts: ReadonlyArray<ProjectScript>; |
| 59 | 66 | /** Scripts declared in the project's checked-in t3.json, offered for import. */ |
| 60 | 67 | fileScripts?: ReadonlyArray<T3ProjectFileScript>; |
| 61 | keybindings: ResolvedKeybindingsConfig; | |
| 62 | 68 | preferredScriptId?: string | null; |
| 63 | 69 | onRunScript: (script: ProjectScript) => void; |
| 64 | 70 | onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; |
| ... | ... | @@ -73,9 +79,9 @@ export default function ProjectScriptsControl({ |
| 73 | 79 | displayMode = "toolbar", |
| 74 | 80 | presentation = "toolbar", |
| 75 | 81 | onRequestMenuClose, |
| 82 | environmentId, | |
| 76 | 83 | scripts, |
| 77 | 84 | fileScripts = NO_FILE_SCRIPTS, |
| 78 | keybindings, | |
| 79 | 85 | preferredScriptId = null, |
| 80 | 86 | onRunScript, |
| 81 | 87 | onAddScript, |
| ... | ... | @@ -85,6 +91,9 @@ export default function ProjectScriptsControl({ |
| 85 | 91 | const isPanel = displayMode === "panel"; |
| 86 | 92 | const ActionGroup = isPanel ? "div" : Group; |
| 87 | 93 | const panelAnchorRef = React.useRef<HTMLDivElement | null>(null); |
| 94 | const keybindings = | |
| 95 | useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? | |
| 96 | DEFAULT_RESOLVED_KEYBINDINGS; | |
| 88 | 97 | const [actionsMenuOpen, setActionsMenuOpen] = useState({ |
| 89 | 98 | presentation, |
| 90 | 99 | scripts: false, |
| ... | ... | @@ -138,7 +147,7 @@ export default function ProjectScriptsControl({ |
| 138 | 147 | icon: fileScript.icon ?? "play", |
| 139 | 148 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, |
| 140 | 149 | waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, |
| 141 | keybinding: null, | |
| 150 | ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}), | |
| 142 | 151 | previewUrl: fileScript.previewUrl ?? null, |
| 143 | 152 | autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, |
| 144 | 153 | }; |
| ... | ... | @@ -459,6 +468,7 @@ export default function ProjectScriptsControl({ |
| 459 | 468 | )} |
| 460 | 469 | |
| 461 | 470 | <ProjectScriptEditorDialog |
| 471 | environmentId={environmentId} | |
| 462 | 472 | request={editorRequest} |
| 463 | 473 | scripts={scripts} |
| 464 | 474 | onSubmit={submitScript} |
apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx+14| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import type { Dispatch, ReactElement, SetStateAction } from "react"; |
| 2 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | AuthProvidersManageScope, | |
| 5 | type AuthEnvironmentScope, | |
| 4 | 6 | type EnvironmentId, |
| 5 | 7 | ProviderDriverKind, |
| 6 | 8 | ProviderInstanceId, |
| ... | ... | @@ -16,6 +18,7 @@ import type { |
| 16 | 18 | |
| 17 | 19 | const testState = vi.hoisted(() => ({ |
| 18 | 20 | groups: [] as LocalEnvironmentUpdateGroup[], |
| 21 | permittedEnvironmentIds: new Set<EnvironmentId>(), | |
| 19 | 22 | updateProvider: vi.fn(), |
| 20 | 23 | })); |
| 21 | 24 | |
| ... | ... | @@ -90,6 +93,15 @@ vi.mock("~/state/server", () => ({ |
| 90 | 93 | serverEnvironment: { updateProvider: Symbol("updateProvider") }, |
| 91 | 94 | })); |
| 92 | 95 | |
| 96 | vi.mock("~/state/session", () => ({ | |
| 97 | readEnvironmentScope: (environmentId: EnvironmentId, scope: AuthEnvironmentScope) => | |
| 98 | scope === AuthProvidersManageScope && testState.permittedEnvironmentIds.has(environmentId), | |
| 99 | useEnvironmentScope: (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => | |
| 100 | environmentId !== null && | |
| 101 | scope === AuthProvidersManageScope && | |
| 102 | testState.permittedEnvironmentIds.has(environmentId), | |
| 103 | })); | |
| 104 | ||
| 93 | 105 | vi.mock("~/state/use-atom-command", () => ({ |
| 94 | 106 | useAtomCommand: () => testState.updateProvider, |
| 95 | 107 | })); |
| ... | ... | @@ -176,6 +188,8 @@ describe("ProviderUpdateEnvironmentRows", () => { |
| 176 | 188 | beforeEach(() => { |
| 177 | 189 | vi.useFakeTimers(); |
| 178 | 190 | hooks.reset(); |
| 191 | testState.permittedEnvironmentIds.clear(); | |
| 192 | testState.permittedEnvironmentIds.add(environmentId); | |
| 179 | 193 | testState.updateProvider.mockReset(); |
| 180 | 194 | const candidate = provider() as ProviderUpdateCandidate; |
| 181 | 195 | testState.groups = [ |
apps/web/src/components/ProviderUpdateEnvironmentRows.tsx+15-3| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { CheckIcon } from "lucide-react"; |
| 2 | 2 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; |
| 3 | import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | AuthProvidersManageScope, | |
| 5 | type EnvironmentId, | |
| 6 | type ServerProvider, | |
| 7 | } from "@t3tools/contracts"; | |
| 4 | 8 | import { |
| 5 | 9 | isAtomCommandInterrupted, |
| 6 | 10 | squashAtomCommandFailure, |
| ... | ... | @@ -9,6 +13,7 @@ import { |
| 9 | 13 | |
| 10 | 14 | import { cn } from "~/lib/utils"; |
| 11 | 15 | import { serverEnvironment } from "~/state/server"; |
| 16 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 12 | 17 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 13 | 18 | import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; |
| 14 | 19 | import { |
| ... | ... | @@ -115,6 +120,7 @@ function EnvironmentUpdateRow({ |
| 115 | 120 | readonly status: ProviderUpdateRowStatus; |
| 116 | 121 | readonly onUpdate: () => void; |
| 117 | 122 | }) { |
| 123 | const canManageProviders = useEnvironmentScope(group.environmentId, AuthProvidersManageScope); | |
| 118 | 124 | let trailing: ReactNode; |
| 119 | 125 | switch (status.kind) { |
| 120 | 126 | case "loading": |
| ... | ... | @@ -126,14 +132,14 @@ function EnvironmentUpdateRow({ |
| 126 | 132 | case "failed": |
| 127 | 133 | case "unchanged": |
| 128 | 134 | trailing = ( |
| 129 | <Button size="xs" variant="outline" onClick={onUpdate}> | |
| 135 | <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}> | |
| 130 | 136 | Retry |
| 131 | 137 | </Button> |
| 132 | 138 | ); |
| 133 | 139 | break; |
| 134 | 140 | default: |
| 135 | 141 | trailing = ( |
| 136 | <Button size="xs" variant="outline" onClick={onUpdate}> | |
| 142 | <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}> | |
| 137 | 143 | Update |
| 138 | 144 | </Button> |
| 139 | 145 | ); |
| ... | ... | @@ -145,6 +151,11 @@ function EnvironmentUpdateRow({ |
| 145 | 151 | <div className="flex min-w-0 flex-col"> |
| 146 | 152 | <span className="truncate font-medium text-foreground">{group.label}</span> |
| 147 | 153 | <span className={cn("truncate text-xs", rowToneClass(status.kind))}>{status.text}</span> |
| 154 | {!canManageProviders ? ( | |
| 155 | <span className="text-xs text-muted-foreground"> | |
| 156 | This connection cannot manage provider accounts. | |
| 157 | </span> | |
| 158 | ) : null} | |
| 148 | 159 | </div> |
| 149 | 160 | <div className="shrink-0">{trailing}</div> |
| 150 | 161 | </div> |
| ... | ... | @@ -211,6 +222,7 @@ export function ProviderUpdateEnvironmentRows({ |
| 211 | 222 | |
| 212 | 223 | const handleUpdate = useCallback( |
| 213 | 224 | async (environmentId: EnvironmentId) => { |
| 225 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 214 | 226 | const group = groupByEnvironment.get(environmentId); |
| 215 | 227 | if (!group || group.candidates.length === 0) { |
| 216 | 228 | return; |
apps/web/src/components/ProviderUpdatePrimaryNotification.tsx+83-39| ... | ... | @@ -2,10 +2,15 @@ import { useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { DownloadIcon } from "lucide-react"; |
| 4 | 4 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 5 | import { type ServerProvider, type ProviderInstanceId } from "@t3tools/contracts"; | |
| 5 | import { | |
| 6 | AuthProvidersManageScope, | |
| 7 | type ServerProvider, | |
| 8 | type ProviderInstanceId, | |
| 9 | } from "@t3tools/contracts"; | |
| 6 | 10 | |
| 7 | 11 | import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; |
| 8 | 12 | import { usePrimaryEnvironment } from "../state/environments"; |
| 13 | import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; | |
| 9 | 14 | import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; |
| 10 | 15 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 11 | 16 | import { |
| ... | ... | @@ -27,7 +32,12 @@ const seenProviderUpdateNotificationKeys = new Set<string>(); |
| 27 | 32 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; |
| 28 | 33 | |
| 29 | 34 | type ActiveProviderUpdateToast = |
| 30 | | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } | |
| 35 | | { | |
| 36 | readonly kind: "prompt"; | |
| 37 | readonly key: string; | |
| 38 | readonly toastId: ProviderUpdateToastId; | |
| 39 | readonly canManageProviders: boolean; | |
| 40 | } | |
| 31 | 41 | | { |
| 32 | 42 | readonly kind: "update"; |
| 33 | 43 | readonly key: string; |
| ... | ... | @@ -100,6 +110,10 @@ export function ProviderUpdatePrimaryNotification() { |
| 100 | 110 | const navigate = useNavigate(); |
| 101 | 111 | const providers = useAtomValue(primaryServerProvidersAtom); |
| 102 | 112 | const primaryEnvironment = usePrimaryEnvironment(); |
| 113 | const canManageProviders = useEnvironmentScope( | |
| 114 | primaryEnvironment?.environmentId ?? null, | |
| 115 | AuthProvidersManageScope, | |
| 116 | ); | |
| 103 | 117 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { |
| 104 | 118 | reportFailure: false, |
| 105 | 119 | }); |
| ... | ... | @@ -178,11 +192,15 @@ export function ProviderUpdatePrimaryNotification() { |
| 178 | 192 | activeToastRef.current = null; |
| 179 | 193 | } |
| 180 | 194 | |
| 195 | const updateExistingPrompt = | |
| 196 | activeToast?.kind === "prompt" && | |
| 197 | activeToast.key === notificationKey && | |
| 198 | activeToast.canManageProviders !== canManageProviders; | |
| 181 | 199 | if ( |
| 182 | 200 | !notificationKey || |
| 183 | 201 | dismissedNotificationKeys.has(notificationKey) || |
| 184 | seenProviderUpdateNotificationKeys.has(notificationKey) || | |
| 185 | activeToastRef.current | |
| 202 | (seenProviderUpdateNotificationKeys.has(notificationKey) && !updateExistingPrompt) || | |
| 203 | (activeToastRef.current && !updateExistingPrompt) | |
| 186 | 204 | ) { |
| 187 | 205 | return; |
| 188 | 206 | } |
| ... | ... | @@ -199,7 +217,12 @@ export function ProviderUpdatePrimaryNotification() { |
| 199 | 217 | }; |
| 200 | 218 | |
| 201 | 219 | const runUpdates = () => { |
| 202 | if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { | |
| 220 | if ( | |
| 221 | updateStarted || | |
| 222 | oneClickProviders.length === 0 || | |
| 223 | !primaryEnvironment || | |
| 224 | !readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope) | |
| 225 | ) { | |
| 203 | 226 | return; |
| 204 | 227 | } |
| 205 | 228 | updateStarted = true; |
| ... | ... | @@ -219,6 +242,19 @@ export function ProviderUpdatePrimaryNotification() { |
| 219 | 242 | void (async () => { |
| 220 | 243 | const results = []; |
| 221 | 244 | for (const provider of oneClickProviders) { |
| 245 | if (!readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)) { | |
| 246 | if (activeToastRef.current === activeUpdate) { | |
| 247 | addProviderUpdateToast({ | |
| 248 | view: getProviderUpdateRejectedToastView( | |
| 249 | providerCount, | |
| 250 | "This connection cannot manage provider accounts.", | |
| 251 | ), | |
| 252 | openSettings: openProviderSettings, | |
| 253 | }); | |
| 254 | activeToastRef.current = null; | |
| 255 | } | |
| 256 | return; | |
| 257 | } | |
| 222 | 258 | results.push( |
| 223 | 259 | await updateProvider({ |
| 224 | 260 | environmentId: primaryEnvironment.environmentId, |
| ... | ... | @@ -260,44 +296,52 @@ export function ProviderUpdatePrimaryNotification() { |
| 260 | 296 | })(); |
| 261 | 297 | }; |
| 262 | 298 | |
| 263 | toastId = toastManager.add( | |
| 264 | stackedThreadToast({ | |
| 265 | type: initialView.type, | |
| 266 | title: initialView.title, | |
| 267 | description: initialView.description, | |
| 268 | timeout: 0, | |
| 269 | actionProps: | |
| 270 | oneClickProviders.length > 0 | |
| 271 | ? { | |
| 272 | children: "Update", | |
| 273 | onClick: runUpdates, | |
| 274 | } | |
| 275 | : { | |
| 299 | const toastOptions = stackedThreadToast({ | |
| 300 | type: initialView.type, | |
| 301 | title: initialView.title, | |
| 302 | description: canManageProviders | |
| 303 | ? initialView.description | |
| 304 | : "This connection cannot manage provider accounts. View provider settings for update details.", | |
| 305 | timeout: 0, | |
| 306 | actionProps: | |
| 307 | oneClickProviders.length > 0 | |
| 308 | ? { | |
| 309 | children: "Update", | |
| 310 | disabled: !canManageProviders, | |
| 311 | onClick: runUpdates, | |
| 312 | } | |
| 313 | : { | |
| 314 | children: "Settings", | |
| 315 | onClick: openSettings, | |
| 316 | }, | |
| 317 | actionVariant: "outline", | |
| 318 | data: { | |
| 319 | leadingIcon: | |
| 320 | updateProviders.length === 1 ? ( | |
| 321 | <ProviderUpdateToastIcon provider={updateProviders[0]!} /> | |
| 322 | ) : undefined, | |
| 323 | hideCopyButton: true, | |
| 324 | onClose: dismissPrompt, | |
| 325 | ...(oneClickProviders.length > 0 | |
| 326 | ? { | |
| 327 | secondaryActionProps: { | |
| 276 | 328 | children: "Settings", |
| 277 | 329 | onClick: openSettings, |
| 278 | 330 | }, |
| 279 | actionVariant: "outline", | |
| 280 | data: { | |
| 281 | leadingIcon: | |
| 282 | updateProviders.length === 1 ? ( | |
| 283 | <ProviderUpdateToastIcon provider={updateProviders[0]!} /> | |
| 284 | ) : undefined, | |
| 285 | hideCopyButton: true, | |
| 286 | onClose: dismissPrompt, | |
| 287 | ...(oneClickProviders.length > 0 | |
| 288 | ? { | |
| 289 | secondaryActionProps: { | |
| 290 | children: "Settings", | |
| 291 | onClick: openSettings, | |
| 292 | }, | |
| 293 | secondaryActionVariant: "outline" as const, | |
| 294 | } | |
| 295 | : {}), | |
| 296 | }, | |
| 297 | }), | |
| 298 | ); | |
| 299 | activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; | |
| 331 | secondaryActionVariant: "outline" as const, | |
| 332 | } | |
| 333 | : {}), | |
| 334 | }, | |
| 335 | }); | |
| 336 | if (updateExistingPrompt && activeToast?.kind === "prompt") { | |
| 337 | toastId = activeToast.toastId; | |
| 338 | toastManager.update(toastId, toastOptions); | |
| 339 | } else { | |
| 340 | toastId = toastManager.add(toastOptions); | |
| 341 | } | |
| 342 | activeToastRef.current = { kind: "prompt", key: notificationKey, toastId, canManageProviders }; | |
| 300 | 343 | }, [ |
| 344 | canManageProviders, | |
| 301 | 345 | updateProvider, |
| 302 | 346 | dismissNotificationKey, |
| 303 | 347 | dismissedNotificationKeys, |
apps/web/src/components/ServerUpdateAction.test.tsx+87-1| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | import { act, type ReactElement } from "react"; |
| 2 | 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts"; | |
| 4 | import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@t3tools/contracts"; | |
| 5 | 5 | import * as Cause from "effect/Cause"; |
| 6 | import * as Schema from "effect/Schema"; | |
| 6 | 7 | import { AsyncResult } from "effect/reactivity"; |
| 7 | 8 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 8 | 9 | |
| ... | ... | @@ -11,6 +12,8 @@ const testState = vi.hoisted(() => ({ |
| 11 | 12 | toast: vi.fn(), |
| 12 | 13 | clipboard: vi.fn(), |
| 13 | 14 | continueThreadsAfterServerUpdate: false, |
| 15 | session: null as AsyncResult.AsyncResult<AuthSessionState, Error> | null, | |
| 16 | sessionAtom: Symbol("session"), | |
| 14 | 17 | })); |
| 15 | 18 | |
| 16 | 19 | vi.mock("~/hooks/useCopyToClipboard", () => ({ |
| ... | ... | @@ -27,6 +30,13 @@ vi.mock("~/hooks/useSettings", () => ({ |
| 27 | 30 | selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, |
| 28 | 31 | ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }), |
| 29 | 32 | })); |
| 33 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => testState.session })); | |
| 34 | vi.mock("~/rpc/atomRegistry", () => ({ | |
| 35 | appAtomRegistry: { get: () => testState.session }, | |
| 36 | })); | |
| 37 | vi.mock("~/state/session", () => ({ | |
| 38 | environmentSession: { sessionStateAtom: () => testState.sessionAtom }, | |
| 39 | })); | |
| 30 | 40 | vi.mock("~/state/server", () => ({ |
| 31 | 41 | serverEnvironment: { updateServer: Symbol("updateServer") }, |
| 32 | 42 | })); |
| ... | ... | @@ -50,6 +60,8 @@ import { |
| 50 | 60 | type ServerUpdateTarget, |
| 51 | 61 | } from "./ServerUpdateAction"; |
| 52 | 62 | |
| 63 | const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState); | |
| 64 | ||
| 53 | 65 | type ActionElement = ReactElement<{ |
| 54 | 66 | readonly onClick?: () => void; |
| 55 | 67 | }>; |
| ... | ... | @@ -68,12 +80,86 @@ async function flushPromises(): Promise<void> { |
| 68 | 80 | await Promise.resolve(); |
| 69 | 81 | } |
| 70 | 82 | |
| 83 | const legacyAuth = { | |
| 84 | policy: "remote-reachable", | |
| 85 | bootstrapMethods: ["one-time-token"], | |
| 86 | sessionMethods: ["bearer-access-token"], | |
| 87 | sessionCookieName: "t3_session", | |
| 88 | } as const; | |
| 89 | ||
| 90 | const currentSession = { | |
| 91 | authenticated: true, | |
| 92 | scopes: ["environment:maintain"], | |
| 93 | auth: { | |
| 94 | ...legacyAuth, | |
| 95 | serverUpdateScope: "environment:maintain", | |
| 96 | }, | |
| 97 | } as const satisfies AuthSessionState; | |
| 98 | ||
| 71 | 99 | describe("ServerUpdateAction", () => { |
| 72 | 100 | beforeEach(() => { |
| 73 | 101 | testState.updateServer.mockReset(); |
| 74 | 102 | testState.toast.mockReset(); |
| 75 | 103 | testState.clipboard.mockReset(); |
| 76 | 104 | testState.continueThreadsAfterServerUpdate = false; |
| 105 | testState.session = AsyncResult.success(currentSession); | |
| 106 | }); | |
| 107 | ||
| 108 | it.each([ | |
| 109 | { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true }, | |
| 110 | { serverUpdateScope: undefined, scopes: ["orchestration:read"], allowed: false }, | |
| 111 | { | |
| 112 | serverUpdateScope: "environment:maintain", | |
| 113 | scopes: ["orchestration:operate"], | |
| 114 | allowed: false, | |
| 115 | }, | |
| 116 | { | |
| 117 | serverUpdateScope: "environment:maintain", | |
| 118 | scopes: ["environment:maintain"], | |
| 119 | allowed: true, | |
| 120 | }, | |
| 121 | ] as const)( | |
| 122 | "uses the advertised update scope $serverUpdateScope with grant $scopes", | |
| 123 | async ({ serverUpdateScope, scopes, allowed }) => { | |
| 124 | testState.session = AsyncResult.success( | |
| 125 | decodeSessionState({ | |
| 126 | ...currentSession, | |
| 127 | scopes, | |
| 128 | auth: { | |
| 129 | ...legacyAuth, | |
| 130 | ...(serverUpdateScope === undefined ? {} : { serverUpdateScope }), | |
| 131 | }, | |
| 132 | }), | |
| 133 | ); | |
| 134 | testState.updateServer.mockResolvedValue( | |
| 135 | AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), | |
| 136 | ); | |
| 137 | ||
| 138 | renderAction().props.onClick?.(); | |
| 139 | await flushPromises(); | |
| 140 | ||
| 141 | expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0); | |
| 142 | }, | |
| 143 | ); | |
| 144 | ||
| 145 | it("keeps a known grant usable while the session refreshes", async () => { | |
| 146 | testState.session = AsyncResult.waiting(AsyncResult.success(currentSession)); | |
| 147 | testState.updateServer.mockResolvedValue( | |
| 148 | AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), | |
| 149 | ); | |
| 150 | ||
| 151 | renderAction().props.onClick?.(); | |
| 152 | await flushPromises(); | |
| 153 | ||
| 154 | expect(testState.updateServer).toHaveBeenCalledOnce(); | |
| 155 | }); | |
| 156 | ||
| 157 | it("does not dispatch an update after maintenance access is removed", async () => { | |
| 158 | const action = renderAction(); | |
| 159 | testState.session = AsyncResult.success({ ...currentSession, scopes: [] }); | |
| 160 | action.props.onClick?.(); | |
| 161 | await flushPromises(); | |
| 162 | expect(testState.updateServer).not.toHaveBeenCalled(); | |
| 77 | 163 | }); |
| 78 | 164 | |
| 79 | 165 | it.each([ |
apps/web/src/components/ServerUpdateAction.tsx+36-3| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts"; | |
| 3 | import type { AsyncResult } from "effect/reactivity"; | |
| 4 | import { environmentSession } from "~/state/session"; | |
| 1 | 5 | import type { |
| 2 | 6 | EnvironmentId, |
| 3 | 7 | ServerInstallation, |
| ... | ... | @@ -15,6 +19,7 @@ import { requestConfirmDialog } from "~/confirmDialog"; |
| 15 | 19 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 16 | 20 | import { useEnvironmentSettings } from "~/hooks/useSettings"; |
| 17 | 21 | import { serverEnvironment, updateOutdatedServer } from "~/state/server"; |
| 22 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 18 | 23 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 19 | 24 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| 20 | 25 | import { Button } from "./ui/button"; |
| ... | ... | @@ -60,7 +65,11 @@ function useServerUpdate() { |
| 60 | 65 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); |
| 61 | 66 | return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { |
| 62 | 67 | const { environmentId, serverLabel, selfUpdate, targetVersion } = target; |
| 63 | if (pendingUpdateEnvironmentIds.has(environmentId)) return; | |
| 68 | if ( | |
| 69 | !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) || | |
| 70 | pendingUpdateEnvironmentIds.has(environmentId) | |
| 71 | ) | |
| 72 | return; | |
| 64 | 73 | pendingUpdateEnvironmentIds.add(environmentId); |
| 65 | 74 | try { |
| 66 | 75 | const result = await updateServer({ |
| ... | ... | @@ -151,6 +160,17 @@ export function ServerUpdatesAction({ |
| 151 | 160 | ); |
| 152 | 161 | } |
| 153 | 162 | |
| 163 | function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean { | |
| 164 | if (result._tag !== "Success" || !result.value.authenticated) return false; | |
| 165 | const session = result.value; | |
| 166 | // Only self-update bridges the old authorization protocol. Upgraded servers | |
| 167 | // advertise the new scope even when this client's grant predates it. | |
| 168 | return ( | |
| 169 | session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === | |
| 170 | true | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 154 | 174 | /** |
| 155 | 175 | * One-row status for an in-flight server update: "Downloading…" then |
| 156 | 176 | * "Restarting…". The update is a wait, not a warning: a single pulsing dot |
| ... | ... | @@ -204,6 +224,8 @@ export function ServerUpdateAction({ |
| 204 | 224 | appearance = "button", |
| 205 | 225 | }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) { |
| 206 | 226 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 227 | const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); | |
| 228 | const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); | |
| 207 | 229 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( |
| 208 | 230 | environmentId, |
| 209 | 231 | (settings) => settings.continueThreadsAfterServerUpdate, |
| ... | ... | @@ -232,7 +254,10 @@ export function ServerUpdateAction({ |
| 232 | 254 | }); |
| 233 | 255 | |
| 234 | 256 | const handleUpdate = async () => { |
| 235 | if (pendingUpdateEnvironmentIds.has(environmentId)) { | |
| 257 | if ( | |
| 258 | !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || | |
| 259 | pendingUpdateEnvironmentIds.has(environmentId) | |
| 260 | ) { | |
| 236 | 261 | return; |
| 237 | 262 | } |
| 238 | 263 | if (isDesktopAppUpdate) { |
| ... | ... | @@ -247,6 +272,7 @@ export function ServerUpdateAction({ |
| 247 | 272 | return; |
| 248 | 273 | } |
| 249 | 274 | } |
| 275 | if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return; | |
| 250 | 276 | await update({ |
| 251 | 277 | environmentId, |
| 252 | 278 | serverLabel, |
| ... | ... | @@ -289,6 +315,7 @@ export function ServerUpdateAction({ |
| 289 | 315 | variant="ghost-muted" |
| 290 | 316 | className={className} |
| 291 | 317 | aria-label={`${actionLabel} for ${serverLabel}`} |
| 318 | disabled={manualCommand === null && !canUpdate} | |
| 292 | 319 | onClick={onClick} |
| 293 | 320 | /> |
| 294 | 321 | } |
| ... | ... | @@ -301,7 +328,13 @@ export function ServerUpdateAction({ |
| 301 | 328 | } |
| 302 | 329 | |
| 303 | 330 | return ( |
| 304 | <Button size={size} variant={variant} className={className} onClick={onClick}> | |
| 331 | <Button | |
| 332 | size={size} | |
| 333 | variant={variant} | |
| 334 | className={className} | |
| 335 | disabled={manualCommand === null && !canUpdate} | |
| 336 | onClick={onClick} | |
| 337 | > | |
| 305 | 338 | {actionLabel} |
| 306 | 339 | </Button> |
| 307 | 340 | ); |
apps/web/src/components/chat/ChatComposer.tsx+26-1| ... | ... | @@ -38,6 +38,7 @@ import type { |
| 38 | 38 | SnapShotSource, |
| 39 | 39 | } from "@t3tools/contracts"; |
| 40 | 40 | import { |
| 41 | AuthOrchestrationOperateScope, | |
| 41 | 42 | ProviderDriverKind, |
| 42 | 43 | ProviderInstanceId, |
| 43 | 44 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| ... | ... | @@ -1130,6 +1131,7 @@ import { useDelayedStatus } from "../../hooks/useDelayedStatus"; |
| 1130 | 1131 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 1131 | 1132 | import { usePanelAnimationSettings } from "../../panelAnimations"; |
| 1132 | 1133 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 1134 | import { readEnvironmentScope } from "../../state/session"; | |
| 1133 | 1135 | import { serverEnvironment } from "../../state/server"; |
| 1134 | 1136 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 1135 | 1137 | |
| ... | ... | @@ -1348,6 +1350,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1348 | 1350 | |
| 1349 | 1351 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { |
| 1350 | 1352 | compact: boolean; |
| 1353 | canOperateThread: boolean; | |
| 1351 | 1354 | activeContextWindow: ContextWindowSnapshot | null; |
| 1352 | 1355 | reserveContextWindowMeter: boolean; |
| 1353 | 1356 | activeThreadModelDisplayName: string | null; |
| ... | ... | @@ -1397,6 +1400,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 1397 | 1400 | ) : null} |
| 1398 | 1401 | <ComposerPrimaryActions |
| 1399 | 1402 | compact={props.compact} |
| 1403 | canOperateThread={props.canOperateThread} | |
| 1400 | 1404 | pendingAction={props.pendingAction} |
| 1401 | 1405 | isRunning={props.isRunning} |
| 1402 | 1406 | canInterrupt={props.canInterrupt} |
| ... | ... | @@ -1499,6 +1503,7 @@ export interface ChatComposerHandle { |
| 1499 | 1503 | export interface ChatComposerProps { |
| 1500 | 1504 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 1501 | 1505 | environmentId: EnvironmentId; |
| 1506 | canOperateThread: boolean; | |
| 1502 | 1507 | attachmentUploadsCapabilityKnown: boolean; |
| 1503 | 1508 | supportsAttachmentUploads: boolean; |
| 1504 | 1509 | supportsQuestionAttachments: boolean; |
| ... | ... | @@ -1685,6 +1690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1685 | 1690 | const { |
| 1686 | 1691 | composerDraftTarget, |
| 1687 | 1692 | environmentId, |
| 1693 | canOperateThread, | |
| 1688 | 1694 | attachmentUploadsCapabilityKnown, |
| 1689 | 1695 | supportsAttachmentUploads, |
| 1690 | 1696 | supportsQuestionAttachments, |
| ... | ... | @@ -2001,6 +2007,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2001 | 2007 | } |
| 2002 | 2008 | return; |
| 2003 | 2009 | } |
| 2010 | if (!canOperateThread) return; | |
| 2004 | 2011 | const invalidFiles = |
| 2005 | 2012 | maxFileAttachmentBytes === null |
| 2006 | 2013 | ? composerFiles |
| ... | ... | @@ -2027,6 +2034,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2027 | 2034 | }, [ |
| 2028 | 2035 | attachmentUploadsCapabilityKnown, |
| 2029 | 2036 | attachmentDraftTarget, |
| 2037 | canOperateThread, | |
| 2030 | 2038 | composerFiles, |
| 2031 | 2039 | composerImages, |
| 2032 | 2040 | environmentId, |
| ... | ... | @@ -4147,7 +4155,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4147 | 4155 | dispatchMode?: ComposerDispatchMode, |
| 4148 | 4156 | submissionIntent?: ComposerSubmissionIntent, |
| 4149 | 4157 | ) => { |
| 4150 | if (noProviderAvailable || isSendDisabled) { | |
| 4158 | if ( | |
| 4159 | noProviderAvailable || | |
| 4160 | isSendDisabled || | |
| 4161 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) | |
| 4162 | ) { | |
| 4151 | 4163 | event?.preventDefault(); |
| 4152 | 4164 | return; |
| 4153 | 4165 | } |
| ... | ... | @@ -4210,6 +4222,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4210 | 4222 | activePendingProgress, |
| 4211 | 4223 | attachmentTargetKey, |
| 4212 | 4224 | blurMobileComposerAfterSend, |
| 4225 | environmentId, | |
| 4213 | 4226 | isSendDisabled, |
| 4214 | 4227 | noProviderAvailable, |
| 4215 | 4228 | onSend, |
| ... | ... | @@ -4245,6 +4258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4245 | 4258 | }, [phase, settings.followUpBehavior, submitComposer]); |
| 4246 | 4259 | const compactThreadContext = useCallback(() => { |
| 4247 | 4260 | if ( |
| 4261 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 4248 | 4262 | compactDisabled || |
| 4249 | 4263 | noProviderAvailable || |
| 4250 | 4264 | activePendingApproval !== null || |
| ... | ... | @@ -4261,6 +4275,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4261 | 4275 | activePendingApproval, |
| 4262 | 4276 | activeThreadId, |
| 4263 | 4277 | compactDisabled, |
| 4278 | composerDraftTarget, | |
| 4279 | environmentId, | |
| 4264 | 4280 | isConnecting, |
| 4265 | 4281 | isSendBusy, |
| 4266 | 4282 | noProviderAvailable, |
| ... | ... | @@ -6650,6 +6666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6650 | 6666 | <ComposerBanner.Actions> |
| 6651 | 6667 | <ComposerPendingApprovalActions |
| 6652 | 6668 | requestId={activePendingApproval.requestId} |
| 6669 | disabled={!canOperateThread} | |
| 6653 | 6670 | isResponding={respondingRequestIds.includes( |
| 6654 | 6671 | activePendingApproval.requestId, |
| 6655 | 6672 | )} |
| ... | ... | @@ -6662,6 +6679,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6662 | 6679 | ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? ( |
| 6663 | 6680 | <ComposerPendingUserInputPanel |
| 6664 | 6681 | pendingUserInputs={pendingUserInputs} |
| 6682 | disabled={!canOperateThread} | |
| 6665 | 6683 | respondingRequestIds={ |
| 6666 | 6684 | activePendingIsResponding && activePendingUserInput |
| 6667 | 6685 | ? [...respondingRequestIds, activePendingUserInput.requestId] |
| ... | ... | @@ -6682,6 +6700,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6682 | 6700 | <div data-chat-composer-collapsed-controls="true"> |
| 6683 | 6701 | <ComposerPendingUserInputPanel |
| 6684 | 6702 | pendingUserInputs={pendingUserInputs} |
| 6703 | disabled={!canOperateThread} | |
| 6685 | 6704 | respondingRequestIds={ |
| 6686 | 6705 | activePendingIsResponding && activePendingUserInput |
| 6687 | 6706 | ? [...respondingRequestIds, activePendingUserInput.requestId] |
| ... | ... | @@ -6723,6 +6742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6723 | 6742 | {activePendingProgress?.activeQuestion?.multiSelect ? ( |
| 6724 | 6743 | <ComposerPrimaryActions |
| 6725 | 6744 | compact |
| 6745 | canOperateThread={canOperateThread} | |
| 6726 | 6746 | pendingAction={pendingPrimaryAction} |
| 6727 | 6747 | isRunning={false} |
| 6728 | 6748 | canInterrupt={false} |
| ... | ... | @@ -7079,6 +7099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7079 | 7099 | draftTarget: attachmentDraftTarget, |
| 7080 | 7100 | }) |
| 7081 | 7101 | } |
| 7102 | disabled={!canOperateThread} | |
| 7082 | 7103 | aria-label={`Retry upload for ${image.name}`} |
| 7083 | 7104 | /> |
| 7084 | 7105 | } |
| ... | ... | @@ -7178,6 +7199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7178 | 7199 | draftTarget: attachmentDraftTarget, |
| 7179 | 7200 | }) |
| 7180 | 7201 | } |
| 7202 | disabled={!canOperateThread} | |
| 7181 | 7203 | aria-label={`Retry upload for ${file.name}`} |
| 7182 | 7204 | /> |
| 7183 | 7205 | } |
| ... | ... | @@ -7256,6 +7278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7256 | 7278 | draftTarget: attachmentDraftTarget, |
| 7257 | 7279 | }) |
| 7258 | 7280 | } |
| 7281 | disabled={!canOperateThread} | |
| 7259 | 7282 | aria-label={`Retry upload for ${file.name}`} |
| 7260 | 7283 | /> |
| 7261 | 7284 | } |
| ... | ... | @@ -7408,6 +7431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7408 | 7431 | > |
| 7409 | 7432 | <ComposerPrimaryActions |
| 7410 | 7433 | compact |
| 7434 | canOperateThread={canOperateThread} | |
| 7411 | 7435 | pendingAction={pendingPrimaryAction} |
| 7412 | 7436 | isRunning={false} |
| 7413 | 7437 | canInterrupt={false} |
| ... | ... | @@ -7513,6 +7537,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7513 | 7537 | ) : null} |
| 7514 | 7538 | <ComposerFooterPrimaryActions |
| 7515 | 7539 | compact={isComposerResting || isComposerPrimaryActionsCompact} |
| 7540 | canOperateThread={canOperateThread} | |
| 7516 | 7541 | activeContextWindow={ |
| 7517 | 7542 | settings.contextWindowMeterEnabled ? activeContextWindow : null |
| 7518 | 7543 | } |
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+14-5| ... | ... | @@ -14,6 +14,7 @@ interface ComposerPendingApprovalActionsProps { |
| 14 | 14 | requestId: RuntimeRequestId; |
| 15 | 15 | isResponding: boolean; |
| 16 | 16 | canRespond: boolean; |
| 17 | disabled?: boolean; | |
| 17 | 18 | options?: ReadonlyArray<ProviderApprovalOption> | undefined; |
| 18 | 19 | onRespondToApproval: ( |
| 19 | 20 | requestId: RuntimeRequestId, |
| ... | ... | @@ -32,6 +33,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro |
| 32 | 33 | requestId, |
| 33 | 34 | isResponding, |
| 34 | 35 | canRespond, |
| 36 | disabled = false, | |
| 35 | 37 | options = DEFAULT_APPROVAL_OPTIONS, |
| 36 | 38 | onRespondToApproval, |
| 37 | 39 | }: ComposerPendingApprovalActionsProps) { |
| ... | ... | @@ -50,9 +52,13 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro |
| 50 | 52 | key={option.decision} |
| 51 | 53 | size="xs" |
| 52 | 54 | variant={option.decision === "accept" ? "default" : "outline"} |
| 53 | disabled={isResponding || !canRespond} | |
| 55 | disabled={disabled || isResponding || !canRespond} | |
| 54 | 56 | aria-description={option.warning} |
| 55 | onClick={() => void onRespondToApproval(requestId, option.decision)} | |
| 57 | onClick={() => { | |
| 58 | if (!disabled && !isResponding) { | |
| 59 | void onRespondToApproval(requestId, option.decision); | |
| 60 | } | |
| 61 | }} | |
| 56 | 62 | > |
| 57 | 63 | {option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null} |
| 58 | 64 | <span className="max-w-40 truncate">{option.label}</span> |
| ... | ... | @@ -70,7 +76,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro |
| 70 | 76 | {moreOptions.length > 0 ? ( |
| 71 | 77 | <Menu> |
| 72 | 78 | <MenuTrigger |
| 73 | disabled={isResponding} | |
| 79 | disabled={disabled || isResponding} | |
| 74 | 80 | render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />} |
| 75 | 81 | > |
| 76 | 82 | <EllipsisIcon /> |
| ... | ... | @@ -80,9 +86,12 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro |
| 80 | 86 | const item = ( |
| 81 | 87 | <MenuItem |
| 82 | 88 | key={option.decision} |
| 83 | disabled={isResponding} | |
| 89 | disabled={disabled || isResponding} | |
| 84 | 90 | aria-description={option.warning} |
| 85 | onClick={() => void onRespondToApproval(requestId, option.decision)} | |
| 91 | onClick={() => { | |
| 92 | if (!disabled && !isResponding) | |
| 93 | void onRespondToApproval(requestId, option.decision); | |
| 94 | }} | |
| 86 | 95 | variant="ghost" |
| 87 | 96 | className="mb-1 last:mb-0" |
| 88 | 97 | > |
apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | import { RuntimeRequestId } from "@t3tools/contracts"; | |
| 2 | import { act, type ReactNode } from "react"; | |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | import { expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | vi.mock("../ui/collapsible", () => { | |
| 7 | const Children = ({ children }: { children: ReactNode }) => <>{children}</>; | |
| 8 | return { | |
| 9 | Collapsible: Children, | |
| 10 | CollapsiblePanel: Children, | |
| 11 | CollapsibleTrigger: Children, | |
| 12 | }; | |
| 13 | }); | |
| 14 | ||
| 15 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; | |
| 16 | ||
| 17 | it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => { | |
| 18 | vi.useFakeTimers(); | |
| 19 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 20 | vi.stubGlobal("window", { | |
| 21 | setTimeout: globalThis.setTimeout, | |
| 22 | clearTimeout: globalThis.clearTimeout, | |
| 23 | }); | |
| 24 | vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); | |
| 25 | const onToggleOption = vi.fn(); | |
| 26 | const onAdvance = vi.fn(); | |
| 27 | const onDismiss = vi.fn(); | |
| 28 | const prompt = { | |
| 29 | requestId: RuntimeRequestId.make("scoped-answer"), | |
| 30 | createdAt: "2026-09-05T00:00:00.000Z", | |
| 31 | dismissible: false, | |
| 32 | responseCapability: "live" as const, | |
| 33 | questions: [ | |
| 34 | { | |
| 35 | id: "approach", | |
| 36 | header: "Approach", | |
| 37 | question: "Which approach?", | |
| 38 | options: [{ label: "Incremental", description: "One module at a time" }], | |
| 39 | multiSelect: false, | |
| 40 | }, | |
| 41 | ], | |
| 42 | }; | |
| 43 | const panel = (disabled: boolean) => ( | |
| 44 | <ComposerPendingUserInputPanel | |
| 45 | pendingUserInputs={[prompt]} | |
| 46 | disabled={disabled} | |
| 47 | respondingRequestIds={[]} | |
| 48 | answers={{}} | |
| 49 | questionIndex={0} | |
| 50 | onToggleOption={onToggleOption} | |
| 51 | onAdvance={onAdvance} | |
| 52 | onDismiss={onDismiss} | |
| 53 | /> | |
| 54 | ); | |
| 55 | let renderer: ReactTestRenderer | undefined; | |
| 56 | const chooseOption = () => { | |
| 57 | const option = renderer!.root | |
| 58 | .findAllByType("button") | |
| 59 | .find((button) => | |
| 60 | button.findAllByType("span").some((span) => span.children.includes("Incremental")), | |
| 61 | ); | |
| 62 | expect(option).toBeDefined(); | |
| 63 | option!.props.onClick(); | |
| 64 | }; | |
| 65 | try { | |
| 66 | await act(async () => { | |
| 67 | renderer = create(panel(false)); | |
| 68 | }); | |
| 69 | await act(async () => chooseOption()); | |
| 70 | expect(onToggleOption).toHaveBeenCalledWith("approach", "Incremental"); | |
| 71 | expect(onAdvance).not.toHaveBeenCalled(); | |
| 72 | ||
| 73 | await act(async () => renderer!.update(panel(true))); | |
| 74 | await act(async () => { | |
| 75 | chooseOption(); | |
| 76 | vi.runAllTimers(); | |
| 77 | }); | |
| 78 | expect(onToggleOption).toHaveBeenCalledTimes(1); | |
| 79 | expect(onAdvance).not.toHaveBeenCalled(); | |
| 80 | ||
| 81 | await act(async () => renderer!.update(panel(false))); | |
| 82 | await act(async () => { | |
| 83 | chooseOption(); | |
| 84 | vi.runAllTimers(); | |
| 85 | }); | |
| 86 | expect(onToggleOption).toHaveBeenCalledTimes(2); | |
| 87 | expect(onAdvance).toHaveBeenCalledTimes(1); | |
| 88 | } finally { | |
| 89 | await act(async () => renderer?.unmount()); | |
| 90 | vi.unstubAllGlobals(); | |
| 91 | vi.useRealTimers(); | |
| 92 | } | |
| 93 | }); |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+18-5| ... | ... | @@ -12,6 +12,7 @@ import { ComposerBanner } from "./ComposerBanner"; |
| 12 | 12 | |
| 13 | 13 | interface PendingUserInputPanelProps { |
| 14 | 14 | pendingUserInputs: PendingUserInput[]; |
| 15 | disabled?: boolean; | |
| 15 | 16 | respondingRequestIds: RuntimeRequestId[]; |
| 16 | 17 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 17 | 18 | questionIndex: number; |
| ... | ... | @@ -22,6 +23,7 @@ interface PendingUserInputPanelProps { |
| 22 | 23 | |
| 23 | 24 | export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ |
| 24 | 25 | pendingUserInputs, |
| 26 | disabled = false, | |
| 25 | 27 | respondingRequestIds, |
| 26 | 28 | answers, |
| 27 | 29 | questionIndex, |
| ... | ... | @@ -37,6 +39,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn |
| 37 | 39 | <ComposerPendingUserInputCard |
| 38 | 40 | key={activePrompt.requestId} |
| 39 | 41 | prompt={activePrompt} |
| 42 | disabled={disabled} | |
| 40 | 43 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} |
| 41 | 44 | answers={answers} |
| 42 | 45 | questionIndex={questionIndex} |
| ... | ... | @@ -49,6 +52,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn |
| 49 | 52 | |
| 50 | 53 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ |
| 51 | 54 | prompt, |
| 55 | disabled, | |
| 52 | 56 | isResponding, |
| 53 | 57 | answers, |
| 54 | 58 | questionIndex, |
| ... | ... | @@ -57,6 +61,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 57 | 61 | onDismiss, |
| 58 | 62 | }: { |
| 59 | 63 | prompt: PendingUserInput; |
| 64 | disabled: boolean; | |
| 60 | 65 | isResponding: boolean; |
| 61 | 66 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 62 | 67 | questionIndex: number; |
| ... | ... | @@ -66,7 +71,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 66 | 71 | }) { |
| 67 | 72 | // Message-mode requests remain answerable after their provider turn ends. |
| 68 | 73 | const canRespond = prompt.responseCapability !== "not_resumable"; |
| 69 | const responseDisabled = isResponding || !canRespond; | |
| 74 | const responseDisabled = disabled || isResponding || !canRespond; | |
| 70 | 75 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 71 | 76 | const activeQuestion = progress.activeQuestion; |
| 72 | 77 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| ... | ... | @@ -88,6 +93,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 88 | 93 | onAdvanceRef.current = onAdvance; |
| 89 | 94 | }, [onAdvance]); |
| 90 | 95 | |
| 96 | useEffect(() => { | |
| 97 | if (disabled && autoAdvanceTimerRef.current !== null) { | |
| 98 | window.clearTimeout(autoAdvanceTimerRef.current); | |
| 99 | autoAdvanceTimerRef.current = null; | |
| 100 | } | |
| 101 | }, [disabled]); | |
| 102 | ||
| 91 | 103 | useEffect(() => { |
| 92 | 104 | if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { |
| 93 | 105 | return; |
| ... | ... | @@ -120,6 +132,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 120 | 132 | |
| 121 | 133 | const handleOptionSelection = useCallback( |
| 122 | 134 | (questionId: string, optionValue: string) => { |
| 135 | if (disabled || isResponding) return; | |
| 123 | 136 | if (activeQuestion?.multiSelect) { |
| 124 | 137 | onToggleOption(questionId, optionValue); |
| 125 | 138 | return; |
| ... | ... | @@ -134,7 +147,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 134 | 147 | onAdvanceRef.current(); |
| 135 | 148 | }, 200); |
| 136 | 149 | }, |
| 137 | [activeQuestion, onToggleOption], | |
| 150 | [activeQuestion, disabled, isResponding, onToggleOption], | |
| 138 | 151 | ); |
| 139 | 152 | |
| 140 | 153 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is |
| ... | ... | @@ -252,8 +265,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 252 | 265 | isSelected |
| 253 | 266 | ? "bg-muted/55 text-foreground" |
| 254 | 267 | : "bg-transparent text-foreground/85 hover:bg-muted/30", |
| 255 | isResponding && "opacity-50 cursor-not-allowed", | |
| 256 | !isResponding && "cursor-pointer", | |
| 268 | (disabled || isResponding) && "opacity-50 cursor-not-allowed", | |
| 269 | !disabled && !isResponding && "cursor-pointer", | |
| 257 | 270 | ); |
| 258 | 271 | const content = ( |
| 259 | 272 | <> |
| ... | ... | @@ -280,7 +293,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 280 | 293 | <button |
| 281 | 294 | key={`${activeQuestion.id}:${optionValue}`} |
| 282 | 295 | type="button" |
| 283 | disabled={isResponding} | |
| 296 | disabled={disabled || isResponding} | |
| 284 | 297 | onClick={() => { |
| 285 | 298 | handleOptionSelection(activeQuestion.id, optionValue); |
| 286 | 299 | }} |
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+2| ... | ... | @@ -21,6 +21,7 @@ function renderPendingActions(isRunning: boolean) { |
| 21 | 21 | return renderToStaticMarkup( |
| 22 | 22 | createElement(ComposerPrimaryActions, { |
| 23 | 23 | compact: true, |
| 24 | canOperateThread: true, | |
| 24 | 25 | pendingAction: { |
| 25 | 26 | questionIndex: 0, |
| 26 | 27 | isLastQuestion: true, |
| ... | ... | @@ -49,6 +50,7 @@ function renderSendButton(sendDisabledReason: string | null = null) { |
| 49 | 50 | return renderToStaticMarkup( |
| 50 | 51 | createElement(ComposerPrimaryActions, { |
| 51 | 52 | compact: true, |
| 53 | canOperateThread: true, | |
| 52 | 54 | pendingAction: null, |
| 53 | 55 | isRunning: false, |
| 54 | 56 | canInterrupt: false, |
apps/web/src/components/chat/ComposerPrimaryActions.tsx+11-3| ... | ... | @@ -26,6 +26,7 @@ interface PendingActionState { |
| 26 | 26 | |
| 27 | 27 | interface ComposerPrimaryActionsProps { |
| 28 | 28 | compact: boolean; |
| 29 | canOperateThread: boolean; | |
| 29 | 30 | pendingAction: PendingActionState | null; |
| 30 | 31 | /** The turn is running: sending steers or queues instead of starting a turn. */ |
| 31 | 32 | isRunning: boolean; |
| ... | ... | @@ -80,6 +81,7 @@ const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => { |
| 80 | 81 | |
| 81 | 82 | export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 82 | 83 | compact, |
| 84 | canOperateThread, | |
| 83 | 85 | pendingAction, |
| 84 | 86 | isRunning, |
| 85 | 87 | canInterrupt, |
| ... | ... | @@ -115,7 +117,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 115 | 117 | alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey, |
| 116 | 118 | }) === "queue"; |
| 117 | 119 | const alternateAction = alternateComposerDispatchAction(followUpBehavior); |
| 118 | const isSendDisabled = sendDisabledReason !== null; | |
| 120 | const isSendDisabled = !canOperateThread || sendDisabledReason !== null; | |
| 119 | 121 | const stageBackdropVariant = useSidebarStageBackdropVariant( |
| 120 | 122 | environmentIdentificationMode === "artwork", |
| 121 | 123 | ); |
| ... | ... | @@ -131,7 +133,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 131 | 133 | insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", |
| 132 | 134 | )} |
| 133 | 135 | {...pointerFocusProps} |
| 134 | onClick={onInterrupt} | |
| 136 | disabled={!canOperateThread} | |
| 137 | onClick={() => { | |
| 138 | if (canOperateThread) onInterrupt(); | |
| 139 | }} | |
| 135 | 140 | aria-label="Stop generation" |
| 136 | 141 | /> |
| 137 | 142 | } |
| ... | ... | @@ -177,6 +182,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 177 | 182 | className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")} |
| 178 | 183 | {...pointerFocusProps} |
| 179 | 184 | disabled={ |
| 185 | !canOperateThread || | |
| 180 | 186 | isEnvironmentUnavailable || |
| 181 | 187 | pendingAction.isResponding || |
| 182 | 188 | (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) |
| ... | ... | @@ -237,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 237 | 243 | <MenuPopup align="end" side="top" {...composerFloatingLayerProps}> |
| 238 | 244 | <MenuItem |
| 239 | 245 | disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} |
| 240 | onClick={() => void onImplementPlanInNewThread()} | |
| 246 | onClick={() => { | |
| 247 | if (canOperateThread) onImplementPlanInNewThread(); | |
| 248 | }} | |
| 241 | 249 | > |
| 242 | 250 | Implement in a new thread |
| 243 | 251 | </MenuItem> |
apps/web/src/components/chat/OpenInPicker.test.tsx created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts"; | |
| 2 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 3 | import { AsyncResult } from "effect/reactivity"; | |
| 4 | import { cloneElement, useState, type ReactElement, type ReactNode } from "react"; | |
| 5 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | import type { RemoteOpenState } from "../../remoteOpen"; | |
| 9 | ||
| 10 | const state = vi.hoisted(() => ({ | |
| 11 | allowed: new Set<string>(), | |
| 12 | remote: { mode: "local-exec" } as RemoteOpenState, | |
| 13 | run: vi.fn(), | |
| 14 | openUrl: vi.fn(), | |
| 15 | setPreferred: vi.fn(), | |
| 16 | markHintSeen: vi.fn(), | |
| 17 | keydown: null as ((event: KeyboardEvent) => void) | null, | |
| 18 | listeners: new Set<() => void>(), | |
| 19 | })); | |
| 20 | ||
| 21 | vi.mock("../../state/session", async () => { | |
| 22 | const { useSyncExternalStore } = await import("react"); | |
| 23 | const readEnvironmentScope = (environmentId: string, scope: string) => | |
| 24 | scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId); | |
| 25 | return { | |
| 26 | readEnvironmentScope, | |
| 27 | useEnvironmentScope: (environmentId: string, scope: string) => | |
| 28 | useSyncExternalStore( | |
| 29 | (listener) => { | |
| 30 | state.listeners.add(listener); | |
| 31 | return () => state.listeners.delete(listener); | |
| 32 | }, | |
| 33 | () => readEnvironmentScope(environmentId, scope), | |
| 34 | ), | |
| 35 | }; | |
| 36 | }); | |
| 37 | vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } })); | |
| 38 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run })); | |
| 39 | vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) })); | |
| 40 | vi.mock("../../editorPreferences", () => ({ | |
| 41 | usePreferredEditor: (available: readonly EditorId[]) => [ | |
| 42 | available[0] ?? null, | |
| 43 | state.setPreferred, | |
| 44 | ], | |
| 45 | })); | |
| 46 | vi.mock("../../remoteOpen", () => ({ | |
| 47 | useRemoteOpenState: () => state.remote, | |
| 48 | useRemoteCapableEditors: () => editors, | |
| 49 | useRemoteOpenHint: () => [false, state.markHintSeen], | |
| 50 | openRemoteEditorUrl: state.openUrl, | |
| 51 | })); | |
| 52 | vi.mock("../../keybindings", () => ({ | |
| 53 | isOpenFavoriteEditorShortcut: () => true, | |
| 54 | shortcutLabelForCommand: () => "Ctrl+O", | |
| 55 | })); | |
| 56 | vi.mock("../ui/button", () => ({ Button: "button" })); | |
| 57 | vi.mock("../ui/group", () => ({ Group: "div", GroupSeparator: "span" })); | |
| 58 | vi.mock("../ui/menu", () => ({ | |
| 59 | Menu: ({ | |
| 60 | open, | |
| 61 | onOpenChange, | |
| 62 | children, | |
| 63 | }: { | |
| 64 | open?: boolean; | |
| 65 | onOpenChange?: (open: boolean) => void; | |
| 66 | children: ReactNode; | |
| 67 | }) => { | |
| 68 | const [uncontrolledOpen, setUncontrolledOpen] = useState(false); | |
| 69 | const visible = open ?? uncontrolledOpen; | |
| 70 | return ( | |
| 71 | <div | |
| 72 | data-menu-open={visible} | |
| 73 | onDoubleClick={() => { | |
| 74 | setUncontrolledOpen(!visible); | |
| 75 | onOpenChange?.(!visible); | |
| 76 | }} | |
| 77 | > | |
| 78 | {children} | |
| 79 | </div> | |
| 80 | ); | |
| 81 | }, | |
| 82 | MenuTrigger: ({ | |
| 83 | render, | |
| 84 | children, | |
| 85 | disabled, | |
| 86 | }: { | |
| 87 | render: ReactElement<{ disabled?: boolean }>; | |
| 88 | children: ReactNode; | |
| 89 | disabled?: boolean; | |
| 90 | }) => cloneElement(render, disabled === undefined ? {} : { disabled }, children), | |
| 91 | MenuPopup: "section", | |
| 92 | MenuItem: "button", | |
| 93 | MenuItemLabel: "span", | |
| 94 | MenuShortcut: "span", | |
| 95 | })); | |
| 96 | ||
| 97 | import { OpenInPicker } from "./OpenInPicker"; | |
| 98 | ||
| 99 | const primary = EnvironmentId.make("primary"); | |
| 100 | const selected = EnvironmentId.make("selected"); | |
| 101 | const editors: readonly EditorId[] = ["vscode"]; | |
| 102 | let renderer: ReactTestRenderer | undefined; | |
| 103 | ||
| 104 | beforeEach(() => { | |
| 105 | state.allowed.clear(); | |
| 106 | state.remote = { mode: "local-exec" }; | |
| 107 | state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 108 | state.openUrl.mockReset().mockResolvedValue(true); | |
| 109 | state.setPreferred.mockReset(); | |
| 110 | state.markHintSeen.mockReset(); | |
| 111 | state.keydown = null; | |
| 112 | state.listeners.clear(); | |
| 113 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 114 | vi.stubGlobal("navigator", { platform: "Linux" }); | |
| 115 | vi.stubGlobal("window", { | |
| 116 | addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => { | |
| 117 | state.keydown = callback; | |
| 118 | }, | |
| 119 | removeEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => { | |
| 120 | if (state.keydown === callback) state.keydown = null; | |
| 121 | }, | |
| 122 | }); | |
| 123 | }); | |
| 124 | ||
| 125 | afterEach(async () => { | |
| 126 | await act(async () => renderer?.unmount()); | |
| 127 | renderer = undefined; | |
| 128 | vi.unstubAllGlobals(); | |
| 129 | }); | |
| 130 | ||
| 131 | async function renderPicker() { | |
| 132 | await act(async () => { | |
| 133 | for (const listener of state.listeners) listener(); | |
| 134 | const node = ( | |
| 135 | <OpenInPicker | |
| 136 | environmentId={selected} | |
| 137 | keybindings={DEFAULT_RESOLVED_KEYBINDINGS} | |
| 138 | availableEditors={editors} | |
| 139 | openInCwd="/work/project" | |
| 140 | compact | |
| 141 | /> | |
| 142 | ); | |
| 143 | if (renderer) renderer.update(node); | |
| 144 | else renderer = create(node); | |
| 145 | }); | |
| 146 | } | |
| 147 | ||
| 148 | function primaryButton() { | |
| 149 | return renderer!.root.findByProps({ "aria-label": "Open file in preferred editor" }); | |
| 150 | } | |
| 151 | ||
| 152 | function keyboardEvent() { | |
| 153 | return { preventDefault: vi.fn() } as unknown as KeyboardEvent; | |
| 154 | } | |
| 155 | ||
| 156 | describe("host editor access", () => { | |
| 157 | it("uses the selected grant for both the button and a retained shortcut", async () => { | |
| 158 | state.allowed.add(selected); | |
| 159 | await renderPicker(); | |
| 160 | const open = primaryButton().props.onClick; | |
| 161 | const keydown = state.keydown!; | |
| 162 | state.allowed.delete(selected); | |
| 163 | state.allowed.add(primary); | |
| 164 | const event = keyboardEvent(); | |
| 165 | await act(async () => { | |
| 166 | open(); | |
| 167 | keydown(event); | |
| 168 | }); | |
| 169 | expect(state.run).not.toHaveBeenCalled(); | |
| 170 | expect(state.setPreferred).not.toHaveBeenCalled(); | |
| 171 | expect(event.preventDefault).not.toHaveBeenCalled(); | |
| 172 | ||
| 173 | await renderPicker(); | |
| 174 | expect(primaryButton().props.disabled).toBe(true); | |
| 175 | expect(renderer!.root.findByProps({ "aria-label": "Choose editor" }).props.disabled).toBe(true); | |
| 176 | expect(state.keydown).toBeNull(); | |
| 177 | }); | |
| 178 | ||
| 179 | it("closes an open picker on revocation and launches only after a fresh grant", async () => { | |
| 180 | state.allowed.add(selected); | |
| 181 | await renderPicker(); | |
| 182 | await act(async () => { | |
| 183 | renderer!.root.findByProps({ "data-menu-open": false }).props.onDoubleClick(); | |
| 184 | }); | |
| 185 | expect(renderer!.root.findByProps({ "data-menu-open": true })).toBeDefined(); | |
| 186 | state.allowed.delete(selected); | |
| 187 | await renderPicker(); | |
| 188 | expect(renderer!.root.findByProps({ "data-menu-open": false })).toBeDefined(); | |
| 189 | state.allowed.add(selected); | |
| 190 | await renderPicker(); | |
| 191 | expect(renderer!.root.findByProps({ "data-menu-open": false })).toBeDefined(); | |
| 192 | await act(async () => primaryButton().props.onClick()); | |
| 193 | expect(state.run).toHaveBeenCalledExactlyOnceWith({ | |
| 194 | environmentId: selected, | |
| 195 | input: { cwd: "/work/project", editor: "vscode" }, | |
| 196 | }); | |
| 197 | }); | |
| 198 | }); | |
| 199 | ||
| 200 | describe("client editor links", () => { | |
| 201 | it("opens an SSH editor URL without host operate permission", async () => { | |
| 202 | state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; | |
| 203 | await renderPicker(); | |
| 204 | expect(primaryButton().props.disabled).toBe(false); | |
| 205 | await act(async () => primaryButton().props.onClick()); | |
| 206 | expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( | |
| 207 | "vscode://vscode-remote/ssh-remote+test-host/work/project", | |
| 208 | ); | |
| 209 | expect(state.run).not.toHaveBeenCalled(); | |
| 210 | expect(state.markHintSeen).toHaveBeenCalledOnce(); | |
| 211 | expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode"); | |
| 212 | }); | |
| 213 | ||
| 214 | it("does not change preferences when the client rejects the SSH URL", async () => { | |
| 215 | state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } }; | |
| 216 | state.openUrl.mockResolvedValue(false); | |
| 217 | await renderPicker(); | |
| 218 | const event = keyboardEvent(); | |
| 219 | await act(async () => state.keydown!(event)); | |
| 220 | expect(state.openUrl).toHaveBeenCalledOnce(); | |
| 221 | expect(event.preventDefault).toHaveBeenCalledOnce(); | |
| 222 | expect(state.run).not.toHaveBeenCalled(); | |
| 223 | expect(state.setPreferred).not.toHaveBeenCalled(); | |
| 224 | expect(state.markHintSeen).not.toHaveBeenCalled(); | |
| 225 | }); | |
| 226 | }); |
apps/web/src/components/chat/OpenInPicker.tsx+30-6| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | |
| 3 | 4 | buildRemoteOpenUrl, |
| 4 | 5 | EditorId, |
| 5 | 6 | type EnvironmentId, |
| ... | ... | @@ -66,6 +67,8 @@ import { |
| 66 | 67 | THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, |
| 67 | 68 | THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, |
| 68 | 69 | } from "./threadDetailsPanelStyles"; |
| 70 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 71 | import { useComposerMenuState } from "./useComposerMenuState"; | |
| 69 | 72 | |
| 70 | 73 | type OpenInOption = { |
| 71 | 74 | label: string; |
| ... | ... | @@ -223,6 +226,10 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 223 | 226 | const panelAnchorRef = useRef<HTMLDivElement | null>(null); |
| 224 | 227 | const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); |
| 225 | 228 | const remote = useRemoteOpenState(environmentId); |
| 229 | const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 230 | const isHostEditorDenied = remote.mode === "local-exec" && !canOperateHost; | |
| 231 | const canOpenEditor = remote.mode !== "remote-unavailable" && !isHostEditorDenied; | |
| 232 | const [menuOpen, setMenuOpen] = useComposerMenuState(isHostEditorDenied); | |
| 226 | 233 | const remoteCapableEditors = useRemoteCapableEditors(); |
| 227 | 234 | const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); |
| 228 | 235 | const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; |
| ... | ... | @@ -258,6 +265,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 258 | 265 | }); |
| 259 | 266 | return; |
| 260 | 267 | } |
| 268 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 261 | 269 | const result = openInEditorMutation({ |
| 262 | 270 | environmentId, |
| 263 | 271 | input: { |
| ... | ... | @@ -285,18 +293,32 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 285 | 293 | ); |
| 286 | 294 | |
| 287 | 295 | useEffect(() => { |
| 288 | if (!enableShortcut) return; | |
| 296 | if (!enableShortcut || !canOpenEditor) return; | |
| 289 | 297 | const handler = (e: globalThis.KeyboardEvent) => { |
| 290 | 298 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 291 | 299 | if (!openInCwd) return; |
| 292 | 300 | if (!preferredEditor) return; |
| 301 | if ( | |
| 302 | remote.mode === "local-exec" && | |
| 303 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) | |
| 304 | ) | |
| 305 | return; | |
| 293 | 306 | |
| 294 | 307 | e.preventDefault(); |
| 295 | 308 | void openInEditor(preferredEditor); |
| 296 | 309 | }; |
| 297 | 310 | window.addEventListener("keydown", handler); |
| 298 | 311 | return () => window.removeEventListener("keydown", handler); |
| 299 | }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); | |
| 312 | }, [ | |
| 313 | canOpenEditor, | |
| 314 | enableShortcut, | |
| 315 | environmentId, | |
| 316 | keybindings, | |
| 317 | openInCwd, | |
| 318 | openInEditor, | |
| 319 | preferredEditor, | |
| 320 | remote.mode, | |
| 321 | ]); | |
| 300 | 322 | const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open"; |
| 301 | 323 | |
| 302 | 324 | const editorItems = ( |
| ... | ... | @@ -315,6 +337,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 315 | 337 | {options.map(({ label, Icon, value, kind }) => ( |
| 316 | 338 | <MenuItem |
| 317 | 339 | density={presentation === "menu" ? "touch" : "default"} |
| 340 | disabled={!openInCwd || !canOpenEditor} | |
| 318 | 341 | key={value} |
| 319 | 342 | onClick={() => openInEditor(value)} |
| 320 | 343 | > |
| ... | ... | @@ -341,7 +364,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 341 | 364 | <MenuItem |
| 342 | 365 | density={presentation === "menu" ? "touch" : "default"} |
| 343 | 366 | |
| 344 | disabled={!openInCwd || remote.mode === "remote-unavailable"} | |
| 367 | disabled={!openInCwd || !canOpenEditor} | |
| 345 | 368 | onClick={() => openInEditor(preferredEditor)} |
| 346 | 369 | > |
| 347 | 370 | <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} /> |
| ... | ... | @@ -352,7 +375,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 352 | 375 | </MenuItem> |
| 353 | 376 | )} |
| 354 | 377 | <MenuSub> |
| 355 | <MenuSubTrigger density="touch"> | |
| 378 | <MenuSubTrigger density="touch" disabled={isHostEditorDenied}> | |
| 356 | 379 | <SquareArrowOutUpRightIcon className="size-4" /> |
| 357 | 380 | <MenuItemLabel>Open in…</MenuItemLabel> |
| 358 | 381 | </MenuSubTrigger> |
| ... | ... | @@ -376,7 +399,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 376 | 399 | variant={isPanel ? "ghost" : "outline"} |
| 377 | 400 | part="primary" |
| 378 | 401 | panel={isPanel} |
| 379 | disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} | |
| 402 | disabled={!preferredEditor || !openInCwd || !canOpenEditor} | |
| 380 | 403 | onClick={() => openInEditor(preferredEditor)} |
| 381 | 404 | > |
| 382 | 405 | {primaryOption?.Icon ? ( |
| ... | ... | @@ -409,8 +432,9 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 409 | 432 | ) : ( |
| 410 | 433 | <GroupSeparator {...(!compact ? { className: "hidden @3xl/header-actions:block" } : {})} /> |
| 411 | 434 | )} |
| 412 | <Menu> | |
| 435 | <Menu open={menuOpen} onOpenChange={setMenuOpen}> | |
| 413 | 436 | <MenuTrigger |
| 437 | disabled={isHostEditorDenied} | |
| 414 | 438 | render={ |
| 415 | 439 | <ThreadDetailsControl |
| 416 | 440 | aria-label="Choose editor" |
apps/web/src/components/chat/ThreadAutomationsPanel.permissions.test.tsx created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } from "@t3tools/contracts"; | |
| 2 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 3 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const state = vi.hoisted(() => ({ allowed: true, toggle: vi.fn(), run: vi.fn() })); | |
| 6 | vi.mock("../../state/session", () => ({ | |
| 7 | useEnvironmentScope: () => state.allowed, | |
| 8 | readEnvironmentScope: () => state.allowed, | |
| 9 | })); | |
| 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.allowed })); | |
| 11 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); | |
| 12 | vi.mock("../settings/ScheduledTasksSettings", () => ({ | |
| 13 | relativeLabel: () => "later", | |
| 14 | scheduleLabel: () => "daily", | |
| 15 | })); | |
| 16 | vi.mock("../../state/server", () => ({ | |
| 17 | serverEnvironment: { | |
| 18 | scheduledTasksLive: () => null, | |
| 19 | setScheduledTaskEnabled: "toggle", | |
| 20 | runScheduledTaskNow: { permissionAtom: () => null }, | |
| 21 | }, | |
| 22 | })); | |
| 23 | vi.mock("../../state/use-atom-command", () => ({ | |
| 24 | useAtomCommand: (command: string) => (command === "toggle" ? state.toggle : state.run), | |
| 25 | })); | |
| 26 | vi.mock("../../state/query", () => ({ | |
| 27 | useEnvironmentQuery: () => ({ data: { tasks: [task] }, error: null }), | |
| 28 | })); | |
| 29 | vi.mock("../ui/tooltip", () => ({ | |
| 30 | Tooltip: ({ children }: { children: React.ReactNode }) => children, | |
| 31 | TooltipTrigger: ({ render }: { render: React.ReactNode }) => render, | |
| 32 | TooltipPopup: () => null, | |
| 33 | })); | |
| 34 | vi.mock("../ui/toast", () => ({ | |
| 35 | toastManager: { add: vi.fn() }, | |
| 36 | stackedThreadToast: (x: unknown) => x, | |
| 37 | })); | |
| 38 | ||
| 39 | import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; | |
| 40 | const environmentId = EnvironmentId.make("remote"); | |
| 41 | const threadId = ThreadId.make("thread"); | |
| 42 | const task = { | |
| 43 | id: ScheduledTaskId.make("task"), | |
| 44 | threadId, | |
| 45 | title: "Daily review", | |
| 46 | enabled: true, | |
| 47 | schedule: { type: "interval", everyMs: 60000 }, | |
| 48 | lastRunStatus: "never", | |
| 49 | } as ScheduledTask; | |
| 50 | let renderer: ReactTestRenderer; | |
| 51 | beforeEach(() => { | |
| 52 | state.allowed = true; | |
| 53 | state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); | |
| 54 | state.run.mockReset().mockResolvedValue({ _tag: "Success" }); | |
| 55 | }); | |
| 56 | afterEach(async () => { | |
| 57 | if (renderer) await act(async () => renderer.unmount()); | |
| 58 | }); | |
| 59 | ||
| 60 | it.each([true, false])("runs scheduled work only with task permission (%s)", async (allowed) => { | |
| 61 | state.allowed = allowed; | |
| 62 | await act(async () => { | |
| 63 | renderer = create(<ThreadAutomationsPanel environmentId={environmentId} threadId={threadId} />); | |
| 64 | }); | |
| 65 | const run = renderer.root.findAllByProps({ "aria-label": "Run Daily review now" })[0]!; | |
| 66 | await act(async () => { | |
| 67 | await run.props.onClick(); | |
| 68 | }); | |
| 69 | expect(state.run).toHaveBeenCalledTimes(allowed ? 1 : 0); | |
| 70 | }); | |
| 71 | ||
| 72 | it("rejects a retained toggle after task permission is revoked", async () => { | |
| 73 | await act(async () => { | |
| 74 | renderer = create(<ThreadAutomationsPanel environmentId={environmentId} threadId={threadId} />); | |
| 75 | }); | |
| 76 | const toggle = renderer.root | |
| 77 | .findAllByProps({ "aria-label": "Pause Daily review" }) | |
| 78 | .find((node) => node.props.onCheckedChange)!; | |
| 79 | state.allowed = false; | |
| 80 | await act(async () => { | |
| 81 | await toggle.props.onCheckedChange(false); | |
| 82 | }); | |
| 83 | expect(state.toggle).not.toHaveBeenCalled(); | |
| 84 | }); |
apps/web/src/components/chat/ThreadAutomationsPanel.tsx+25-5| ... | ... | @@ -1,8 +1,14 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | 4 | import { CalendarClockIcon, PencilIcon, PlayIcon, Settings2Icon } from "lucide-react"; |
| 4 | 5 | import { useState } from "react"; |
| 5 | import type { EnvironmentId, ScheduledTask, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | AuthOrchestrationOperateScope, | |
| 8 | type EnvironmentId, | |
| 9 | type ScheduledTask, | |
| 10 | type ThreadId, | |
| 11 | } from "@t3tools/contracts"; | |
| 6 | 12 | import { |
| 7 | 13 | isAtomCommandInterrupted, |
| 8 | 14 | squashAtomCommandFailure, |
| ... | ... | @@ -15,6 +21,7 @@ import { useEnvironmentQuery } from "../../state/query"; |
| 15 | 21 | import { serverEnvironment } from "../../state/server"; |
| 16 | 22 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 17 | 23 | |
| 24 | import { readEnvironmentScope } from "../../state/session"; | |
| 18 | 25 | import { Switch } from "../ui/switch"; |
| 19 | 26 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 20 | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -40,6 +47,9 @@ export function ThreadAutomationsPanel(props: { |
| 40 | 47 | readonly environmentId: EnvironmentId; |
| 41 | 48 | readonly threadId: ThreadId; |
| 42 | 49 | }) { |
| 50 | const canOperate = useAtomValue( | |
| 51 | serverEnvironment.runScheduledTaskNow.permissionAtom(props.environmentId), | |
| 52 | ); | |
| 43 | 53 | const tasksQuery = useEnvironmentQuery( |
| 44 | 54 | serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }), |
| 45 | 55 | ); |
| ... | ... | @@ -71,7 +81,11 @@ export function ThreadAutomationsPanel(props: { |
| 71 | 81 | }; |
| 72 | 82 | |
| 73 | 83 | const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => { |
| 74 | if (busyTaskId !== null) return; | |
| 84 | if ( | |
| 85 | busyTaskId !== null || | |
| 86 | !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) | |
| 87 | ) | |
| 88 | return; | |
| 75 | 89 | setBusyTaskId(task.id); |
| 76 | 90 | // Partial update: only the enabled flag changes, so a toggle can never |
| 77 | 91 | // revert concurrent edits made to the task elsewhere. |
| ... | ... | @@ -86,7 +100,11 @@ export function ThreadAutomationsPanel(props: { |
| 86 | 100 | }; |
| 87 | 101 | |
| 88 | 102 | const runNow = async (task: ScheduledTask) => { |
| 89 | if (busyTaskId !== null) return; | |
| 103 | if ( | |
| 104 | busyTaskId !== null || | |
| 105 | !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) | |
| 106 | ) | |
| 107 | return; | |
| 90 | 108 | setBusyTaskId(task.id); |
| 91 | 109 | const result = await runTaskNow({ |
| 92 | 110 | environmentId: props.environmentId, |
| ... | ... | @@ -196,7 +214,9 @@ export function ThreadAutomationsPanel(props: { |
| 196 | 214 | variant="ghost" |
| 197 | 215 | part="icon" |
| 198 | 216 | aria-label={`Run ${task.title} now`} |
| 199 | disabled={busyTaskId !== null || task.lastRunStatus === "running"} | |
| 217 | disabled={ | |
| 218 | !canOperate || busyTaskId !== null || task.lastRunStatus === "running" | |
| 219 | } | |
| 200 | 220 | onClick={() => void runNow(task)} |
| 201 | 221 | > |
| 202 | 222 | <PlayIcon className="size-3.5" /> |
| ... | ... | @@ -208,7 +228,7 @@ export function ThreadAutomationsPanel(props: { |
| 208 | 228 | )} |
| 209 | 229 | <Switch |
| 210 | 230 | checked={task.enabled} |
| 211 | disabled={busyTaskId !== null} | |
| 231 | disabled={!canOperate || busyTaskId !== null} | |
| 212 | 232 | aria-label={task.enabled ? `Pause ${task.title}` : `Resume ${task.title}`} |
| 213 | 233 | onCheckedChange={(enabled) => void toggleEnabled(task, enabled)} |
| 214 | 234 | /> |
apps/web/src/components/chat/ThreadDetailsPanel.tsx+2-2| ... | ... | @@ -170,8 +170,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { |
| 170 | 170 | |
| 171 | 171 | {density !== "essential" && props.showOpenInPicker ? ( |
| 172 | 172 | <OpenInPicker |
| 173 | environmentId={props.environmentId} | |
| 174 | 173 | keybindings={props.keybindings} |
| 174 | environmentId={props.environmentId} | |
| 175 | 175 | availableEditors={props.availableEditors} |
| 176 | 176 | openInCwd={props.gitCwd} |
| 177 | 177 | displayMode="panel" |
| ... | ... | @@ -180,10 +180,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { |
| 180 | 180 | |
| 181 | 181 | {props.activeProjectScripts ? ( |
| 182 | 182 | <ProjectScriptsControl |
| 183 | environmentId={props.environmentId} | |
| 183 | 184 | displayMode="panel" |
| 184 | 185 | scripts={props.activeProjectScripts} |
| 185 | 186 | fileScripts={fileScripts} |
| 186 | keybindings={props.keybindings} | |
| 187 | 187 | preferredScriptId={props.preferredScriptId} |
| 188 | 188 | onRunScript={props.onRunProjectScript} |
| 189 | 189 | onAddScript={props.onAddProjectScript} |
apps/web/src/components/chat/TraitsPicker.tsx+5-2| ... | ... | @@ -581,14 +581,16 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 581 | 581 | isComposerOwned, |
| 582 | 582 | size = "sm", |
| 583 | 583 | hidden = false, |
| 584 | disabled = false, | |
| 584 | 585 | ...persistence |
| 585 | 586 | }: TraitsMenuContentProps & |
| 586 | 587 | TraitsPersistence & { |
| 587 | 588 | size?: ComposerControlSize; |
| 588 | 589 | hidden?: boolean; |
| 590 | disabled?: boolean; | |
| 589 | 591 | }) { |
| 590 | 592 | const composerFloatingLayerProps = useComposerMenuProps(); |
| 591 | const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden); | |
| 593 | const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden || disabled); | |
| 592 | 594 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = |
| 593 | 595 | getTraitsSectionVisibility({ |
| 594 | 596 | provider, |
| ... | ... | @@ -627,7 +629,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 627 | 629 | <Menu |
| 628 | 630 | open={isMenuOpen} |
| 629 | 631 | onOpenChange={(open) => { |
| 630 | setIsMenuOpen(open); | |
| 632 | setIsMenuOpen(open && !disabled); | |
| 631 | 633 | }} |
| 632 | 634 | > |
| 633 | 635 | <Tooltip> |
| ... | ... | @@ -636,6 +638,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 636 | 638 | <MenuTrigger |
| 637 | 639 | render={ |
| 638 | 640 | <ComposerControl |
| 641 | disabled={disabled} | |
| 639 | 642 | aria-label={triggerLabel} |
| 640 | 643 | data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined} |
| 641 | 644 | size={size} |
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+69-19| ... | ... | @@ -1,5 +1,12 @@ |
| 1 | 1 | import { useAuth } from "@clerk/react"; |
| 2 | import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts"; | |
| 2 | import { useAtomValue } from "@effect/atom-react"; | |
| 3 | import { | |
| 4 | AuthAdministrativeScopes, | |
| 5 | AuthRelayReadScope, | |
| 6 | AuthRelayWriteScope, | |
| 7 | type AuthSessionState, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 3 | 10 | import { useEffect, useRef, useState } from "react"; |
| 4 | 11 | |
| 5 | 12 | import { |
| ... | ... | @@ -9,9 +16,13 @@ import { |
| 9 | 16 | } from "~/cloud/connectOnboarding"; |
| 10 | 17 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 11 | 18 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| 12 | import { usePrimarySessionState } from "~/environments/primary"; | |
| 13 | 19 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 14 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; | |
| 20 | import { | |
| 21 | useEnvironments, | |
| 22 | usePrimaryEnvironment, | |
| 23 | usePrimaryEnvironmentId, | |
| 24 | } from "~/state/environments"; | |
| 25 | import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 15 | 26 | import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; |
| 16 | 27 | import { Button } from "../ui/button"; |
| 17 | 28 | import { Checkbox } from "../ui/checkbox"; |
| ... | ... | @@ -37,6 +48,8 @@ export function ConnectOnboardingDialog() { |
| 37 | 48 | |
| 38 | 49 | type OnboardingStep = "publish" | "devices"; |
| 39 | 50 | |
| 51 | const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>()); | |
| 52 | ||
| 40 | 53 | function ConfiguredConnectOnboardingDialog() { |
| 41 | 54 | // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as |
| 42 | 55 | // signed-out, or its later activation would look like a fresh sign-in. |
| ... | ... | @@ -47,22 +60,22 @@ function ConfiguredConnectOnboardingDialog() { |
| 47 | 60 | ConnectOnboardingOptOutSchema, |
| 48 | 61 | ); |
| 49 | 62 | |
| 50 | const desktopBridge = window.desktopBridge; | |
| 51 | const primarySessionState = usePrimarySessionState(); | |
| 52 | const currentSessionScopes = desktopBridge | |
| 53 | ? AuthAdministrativeScopes | |
| 54 | : primarySessionState.data?.authenticated | |
| 55 | ? (primarySessionState.data.scopes ?? null) | |
| 56 | : null; | |
| 57 | const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; | |
| 63 | const { isReady: environmentsReady } = useEnvironments(); | |
| 64 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 65 | const primarySessionState = useAtomValue( | |
| 66 | primaryEnvironmentId === null | |
| 67 | ? EMPTY_SESSION_STATE_ATOM | |
| 68 | : environmentSession.sessionStateAtom(primaryEnvironmentId), | |
| 69 | ); | |
| 70 | const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope); | |
| 71 | const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); | |
| 72 | const canManageRelay = canReadRelay && canWriteRelay; | |
| 58 | 73 | // The publish step is only offered when we know the answer; opening the |
| 59 | 74 | // wizard before the session state resolves would let the step set change |
| 60 | 75 | // mid-flight. A failed session read still opens the wizard — it just means |
| 61 | 76 | // no publish step. |
| 62 | 77 | const sessionScopesKnown = |
| 63 | Boolean(desktopBridge) || | |
| 64 | primarySessionState.data !== null || | |
| 65 | primarySessionState.error !== null; | |
| 78 | primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; | |
| 66 | 79 | |
| 67 | 80 | const controller = useCloudLinkController(); |
| 68 | 81 | const showPublishStep = canManageRelay && controller.linkState.target !== null; |
| ... | ... | @@ -80,6 +93,12 @@ function ConfiguredConnectOnboardingDialog() { |
| 80 | 93 | const prefilledFromLinkStateRef = useRef(false); |
| 81 | 94 | const observedAccountRef = useRef<string | null | undefined>(undefined); |
| 82 | 95 | |
| 96 | useEffect(() => { | |
| 97 | if (step === "publish" && sessionScopesKnown && !showPublishStep && !isApplying) { | |
| 98 | setStep("devices"); | |
| 99 | } | |
| 100 | }, [isApplying, sessionScopesKnown, showPublishStep, step]); | |
| 101 | ||
| 83 | 102 | const optOutAccounts = optOutState.optOutAccounts; |
| 84 | 103 | |
| 85 | 104 | // Every sign-in or account switch that completes during this session |
| ... | ... | @@ -176,6 +195,16 @@ function ConfiguredConnectOnboardingDialog() { |
| 176 | 195 | }; |
| 177 | 196 | |
| 178 | 197 | const applyPublishSelection = async () => { |
| 198 | if (isApplying) return; | |
| 199 | if ( | |
| 200 | primaryEnvironmentId === null || | |
| 201 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) || | |
| 202 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) | |
| 203 | ) { | |
| 204 | setStep("devices"); | |
| 205 | return; | |
| 206 | } | |
| 207 | if (linkStateData === null || controller.linkState.error !== null) return; | |
| 179 | 208 | // The wizard only ever enables — with both toggles off there is nothing to |
| 180 | 209 | // apply, and an existing link must not be torn down from onboarding. |
| 181 | 210 | if (!exposeEnvironment && !publishAgentActivity) { |
| ... | ... | @@ -225,6 +254,13 @@ function ConfiguredConnectOnboardingDialog() { |
| 225 | 254 | isStepDisabled={() => isApplying} |
| 226 | 255 | onStepChange={(index) => { |
| 227 | 256 | const next = steps[index]; |
| 257 | if ( | |
| 258 | next === "publish" && | |
| 259 | (primaryEnvironmentId === null || | |
| 260 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) || | |
| 261 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)) | |
| 262 | ) | |
| 263 | return; | |
| 228 | 264 | if (next) setStep(next); |
| 229 | 265 | }} |
| 230 | 266 | /> |
| ... | ... | @@ -235,10 +271,24 @@ function ConfiguredConnectOnboardingDialog() { |
| 235 | 271 | <PublishStep |
| 236 | 272 | exposeEnvironment={exposeEnvironment} |
| 237 | 273 | publishAgentActivity={publishAgentActivity} |
| 238 | disabled={isApplying} | |
| 239 | operationError={controller.operationError} | |
| 240 | onExposeEnvironmentChange={setExposeEnvironment} | |
| 241 | onPublishAgentActivityChange={setPublishAgentActivity} | |
| 274 | disabled={isApplying || !canManageRelay || linkStateData === null} | |
| 275 | operationError={controller.operationError ?? controller.linkState.error} | |
| 276 | onExposeEnvironmentChange={(enabled) => { | |
| 277 | if ( | |
| 278 | primaryEnvironmentId !== null && | |
| 279 | readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) && | |
| 280 | readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) | |
| 281 | ) | |
| 282 | setExposeEnvironment(enabled); | |
| 283 | }} | |
| 284 | onPublishAgentActivityChange={(enabled) => { | |
| 285 | if ( | |
| 286 | primaryEnvironmentId !== null && | |
| 287 | readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) && | |
| 288 | readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) | |
| 289 | ) | |
| 290 | setPublishAgentActivity(enabled); | |
| 291 | }} | |
| 242 | 292 | /> |
| 243 | 293 | ) : ( |
| 244 | 294 | <DevicesStep /> |
| ... | ... | @@ -261,7 +311,7 @@ function ConfiguredConnectOnboardingDialog() { |
| 261 | 311 | Not now |
| 262 | 312 | </Button> |
| 263 | 313 | <Button |
| 264 | disabled={isApplying || (controller.linkState.isPending && linkStateData === null)} | |
| 314 | disabled={isApplying || !canManageRelay || linkStateData === null} | |
| 265 | 315 | onClick={() => void applyPublishSelection()} |
| 266 | 316 | > |
| 267 | 317 | {isApplying ? "Enabling…" : "Continue"} |
apps/web/src/components/device/DeviceSetup.tsx+7-3| ... | ... | @@ -2,6 +2,7 @@ import { DeviceHostUpdates } from "./DeviceHostUpdates"; |
| 2 | 2 | import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | import { Check } from "lucide-react"; |
| 4 | 4 | import { Check as CheckGlyph, CircleAlert } from "lucide"; |
| 5 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 5 | 6 | import { useState } from "react"; |
| 6 | 7 | |
| 7 | 8 | import { Button } from "~/components/ui/button"; |
| ... | ... | @@ -12,6 +13,7 @@ import { Spinner } from "~/components/ui/spinner"; |
| 12 | 13 | import { Switch } from "~/components/ui/switch"; |
| 13 | 14 | import { deviceEnvironment } from "~/state/device"; |
| 14 | 15 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 16 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 15 | 17 | import { cn } from "~/lib/utils"; |
| 16 | 18 | |
| 17 | 19 | const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android"); |
| ... | ... | @@ -57,6 +59,7 @@ export function DeviceSetup(props: { |
| 57 | 59 | readonly state: DeviceServiceState; |
| 58 | 60 | readonly onComplete?: () => void; |
| 59 | 61 | }) { |
| 62 | const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); | |
| 60 | 63 | const configure = useAtomCommand(deviceEnvironment.configure); |
| 61 | 64 | const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); |
| 62 | 65 | const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null); |
| ... | ... | @@ -71,6 +74,7 @@ export function DeviceSetup(props: { |
| 71 | 74 | kind: NonNullable<typeof pending>, |
| 72 | 75 | input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean }, |
| 73 | 76 | ) => { |
| 77 | if (!readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) return; | |
| 74 | 78 | setPending(kind); |
| 75 | 79 | try { |
| 76 | 80 | const result = await configure({ environmentId: props.environmentId, input }); |
| ... | ... | @@ -103,7 +107,7 @@ export function DeviceSetup(props: { |
| 103 | 107 | <p className="text-muted-foreground">{deviceHubDescription}</p> |
| 104 | 108 | <Switch |
| 105 | 109 | checked={enabled} |
| 106 | disabled={busy || pending !== null} | |
| 110 | disabled={!canConfigure || busy || pending !== null} | |
| 107 | 111 | aria-label="Enable device hub" |
| 108 | 112 | onCheckedChange={(checked) => |
| 109 | 113 | void update("hub", { |
| ... | ... | @@ -144,7 +148,7 @@ export function DeviceSetup(props: { |
| 144 | 148 | <p className="text-muted-foreground">{agentDeviceDescription}</p> |
| 145 | 149 | <Switch |
| 146 | 150 | checked={props.state.agentAccessEnabled} |
| 147 | disabled={!enabled || busy || pending !== null} | |
| 151 | disabled={!canConfigure || !enabled || busy || pending !== null} | |
| 148 | 152 | aria-label="Allow agents to control devices" |
| 149 | 153 | onCheckedChange={(checked) => |
| 150 | 154 | void update("agent", { agentAccessEnabled: Boolean(checked) }) |
| ... | ... | @@ -185,7 +189,7 @@ export function DeviceSetup(props: { |
| 185 | 189 | </Button> |
| 186 | 190 | ) : ( |
| 187 | 191 | <Button |
| 188 | disabled={props.state.hostStatus !== "ready" || pending !== null} | |
| 192 | disabled={!canConfigure || props.state.hostStatus !== "ready" || pending !== null} | |
| 189 | 193 | onClick={() => void update("complete", { onboardingCompleted: true })} |
| 190 | 194 | > |
| 191 | 195 | {pending === "complete" ? "Saving…" : "Done"} |
apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx created+403| ... | ... | @@ -0,0 +1,403 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import { | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | AuthOrchestrationReadScope, | |
| 5 | EnvironmentId, | |
| 6 | ProjectId, | |
| 7 | type AgentSessionProjectCandidate, | |
| 8 | type AuthEnvironmentScope, | |
| 9 | type AuthSessionState, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 12 | import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; | |
| 13 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 14 | ||
| 15 | type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>; | |
| 16 | type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string }; | |
| 17 | type CreateProjectInput = { | |
| 18 | environmentId: EnvironmentId; | |
| 19 | input: { projectId: ProjectId; workspaceRoot: string }; | |
| 20 | }; | |
| 21 | const state = vi.hoisted(() => ({ | |
| 22 | selectedEnvironment: "primary", | |
| 23 | registry: null as AtomRegistry.AtomRegistry | null, | |
| 24 | sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(), | |
| 25 | projects: null as Atom.Writable<ReadonlyArray<Project>> | null, | |
| 26 | providers: null as Atom.Atom<ReadonlyArray<never>> | null, | |
| 27 | config: null as Atom.Atom<null> | null, | |
| 28 | candidates: [] as AgentSessionProjectCandidate[], | |
| 29 | createProject: vi.fn(), | |
| 30 | importThreads: vi.fn(), | |
| 31 | refreshProviders: vi.fn(), | |
| 32 | connectPairing: vi.fn(), | |
| 33 | completeOnboarding: vi.fn(), | |
| 34 | onDone: vi.fn(), | |
| 35 | scan: vi.fn(), | |
| 36 | refreshScan: vi.fn(), | |
| 37 | })); | |
| 38 | ||
| 39 | vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined })); | |
| 40 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | |
| 41 | createEnvironmentSessionAtoms: () => ({ | |
| 42 | sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, | |
| 43 | }), | |
| 44 | })); | |
| 45 | vi.mock("../../rpc/atomRegistry", () => ({ | |
| 46 | get appAtomRegistry() { | |
| 47 | return state.registry; | |
| 48 | }, | |
| 49 | })); | |
| 50 | vi.mock("@clerk/react", () => ({ useAuth: () => ({ isLoaded: true, isSignedIn: false }) })); | |
| 51 | vi.mock("../../hooks/useTheme", () => ({ mountOnboardingTheme: () => () => {} })); | |
| 52 | vi.mock("../../hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, () => {}] })); | |
| 53 | vi.mock("../../hooks/useCopyToClipboard", () => ({ | |
| 54 | useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), copied: false }), | |
| 55 | })); | |
| 56 | vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); | |
| 57 | vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); | |
| 58 | vi.mock("../../onboarding/firstRun", () => ({ | |
| 59 | useCompleteOnboarding: () => state.completeOnboarding, | |
| 60 | })); | |
| 61 | vi.mock("../../env", () => ({ isElectron: false })); | |
| 62 | vi.mock("../../state/environments", () => { | |
| 63 | const environment = (environmentId: string) => ({ | |
| 64 | environmentId, | |
| 65 | label: environmentId, | |
| 66 | connection: { phase: "connected" }, | |
| 67 | entry: { target: { _tag: "DirectConnectionTarget" } }, | |
| 68 | }); | |
| 69 | const primary = environment("primary"); | |
| 70 | return { | |
| 71 | usePrimaryEnvironment: () => primary, | |
| 72 | useEnvironments: () => ({ environments: [environment(state.selectedEnvironment)] }), | |
| 73 | }; | |
| 74 | }); | |
| 75 | vi.mock("../../state/server", () => ({ | |
| 76 | serverEnvironment: { | |
| 77 | providersValueAtom: () => state.providers, | |
| 78 | configValueAtom: () => state.config, | |
| 79 | refreshProviders: "refreshProviders", | |
| 80 | }, | |
| 81 | })); | |
| 82 | vi.mock("../../state/entities", async () => { | |
| 83 | const { useAtomValue } = await import("@effect/atom-react"); | |
| 84 | return { | |
| 85 | useProjects: () => useAtomValue(state.projects!), | |
| 86 | readProjects: () => state.registry!.get(state.projects!), | |
| 87 | }; | |
| 88 | }); | |
| 89 | vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "createProject" } })); | |
| 90 | vi.mock("../../state/agentSessions", () => ({ | |
| 91 | agentSessionScan: (query: { environmentId: EnvironmentId }) => query, | |
| 92 | agentSessionImport: "importThreads", | |
| 93 | })); | |
| 94 | vi.mock("../../onboarding/useProjectScans", () => ({ | |
| 95 | useProjectScans: (ids: EnvironmentId[]) => | |
| 96 | ids.map((environmentId) => { | |
| 97 | state.scan({ environmentId }); | |
| 98 | return { | |
| 99 | environmentId, | |
| 100 | data: { candidates: state.candidates, truncated: false }, | |
| 101 | isPending: false, | |
| 102 | error: null, | |
| 103 | refresh: state.refreshScan, | |
| 104 | }; | |
| 105 | }), | |
| 106 | })); | |
| 107 | vi.mock("../ui/dialog", () => ({ | |
| 108 | Dialog: "div", | |
| 109 | DialogPopup: "div", | |
| 110 | DialogTitle: "div", | |
| 111 | DialogHeader: "div", | |
| 112 | })); | |
| 113 | vi.mock("../ui/wizard", () => ({ | |
| 114 | WizardPanel: "div", | |
| 115 | WizardSteps: "div", | |
| 116 | WizardPopup: "div", | |
| 117 | WizardHeader: "div", | |
| 118 | })); | |
| 119 | vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); | |
| 120 | vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipTrigger: "span", TooltipPopup: "span" })); | |
| 121 | vi.mock("../../connection/onboarding", () => ({ connectPairing: "connectPairing" })); | |
| 122 | vi.mock("../../state/use-atom-command", () => ({ | |
| 123 | useAtomCommand: ( | |
| 124 | command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing", | |
| 125 | ) => state[command], | |
| 126 | })); | |
| 127 | vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); | |
| 128 | vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: "div" })); | |
| 129 | vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: "div" })); | |
| 130 | vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); | |
| 131 | vi.mock("../settings/CodexSetupSection", () => ({ | |
| 132 | CodexSetupSection: () => null, | |
| 133 | AddManagedCodexAccountDialog: () => null, | |
| 134 | })); | |
| 135 | vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Agent" }) })); | |
| 136 | vi.mock("../settings/providerStatus", () => ({ | |
| 137 | getProviderSummary: () => ({ headline: "Checking", detail: null }), | |
| 138 | })); | |
| 139 | vi.mock("../ui/button", () => ({ Button: "button" })); | |
| 140 | vi.mock("../ui/input", () => ({ Input: "input" })); | |
| 141 | vi.mock("../ui/checkbox", () => ({ Checkbox: "input" })); | |
| 142 | vi.mock("../ui/collapsible", () => ({ | |
| 143 | Collapsible: "div", | |
| 144 | CollapsiblePanel: "div", | |
| 145 | CollapsibleTrigger: "button", | |
| 146 | })); | |
| 147 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), close: vi.fn(), update: vi.fn() } })); | |
| 148 | ||
| 149 | import { WelcomeWizard } from "./WelcomeWizard"; | |
| 150 | ||
| 151 | const primaryId = EnvironmentId.make("primary"); | |
| 152 | const remoteId = EnvironmentId.make("remote"); | |
| 153 | const permissionMessage = "This connection cannot import projects or thread history."; | |
| 154 | let renderer: ReactTestRenderer | undefined; | |
| 155 | ||
| 156 | function session(scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState { | |
| 157 | return { | |
| 158 | authenticated: true, | |
| 159 | scopes: [AuthOrchestrationReadScope, ...scopes], | |
| 160 | auth: { | |
| 161 | policy: "remote-reachable", | |
| 162 | bootstrapMethods: ["one-time-token"], | |
| 163 | sessionMethods: ["bearer-access-token"], | |
| 164 | sessionCookieName: "t3_session", | |
| 165 | }, | |
| 166 | }; | |
| 167 | } | |
| 168 | ||
| 169 | function setGrant(environmentId: EnvironmentId, allowed: boolean) { | |
| 170 | state.registry!.set( | |
| 171 | state.sessions.get(environmentId)!, | |
| 172 | AsyncResult.success(session(allowed ? [AuthOrchestrationOperateScope] : [])), | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | function candidate(path: string): AgentSessionProjectCandidate { | |
| 177 | return { | |
| 178 | path, | |
| 179 | title: path.split("/").at(-1)!, | |
| 180 | sources: ["codex"], | |
| 181 | threadCount: 10, | |
| 182 | lastActiveAt: new Date(Date.now() - 60_000).toISOString(), | |
| 183 | alreadyImported: false, | |
| 184 | }; | |
| 185 | } | |
| 186 | ||
| 187 | function recordProject({ environmentId, input }: CreateProjectInput) { | |
| 188 | state.registry!.set(state.projects!, [ | |
| 189 | ...state.registry!.get(state.projects!), | |
| 190 | { id: input.projectId, environmentId, workspaceRoot: input.workspaceRoot }, | |
| 191 | ]); | |
| 192 | return AsyncResult.success(undefined); | |
| 193 | } | |
| 194 | ||
| 195 | function deferred<T>() { | |
| 196 | let resolve!: (value: T) => void; | |
| 197 | const promise = new Promise<T>((complete) => { | |
| 198 | resolve = complete; | |
| 199 | }); | |
| 200 | return { promise, resolve }; | |
| 201 | } | |
| 202 | ||
| 203 | function text(node: ReactTestInstance | string): string { | |
| 204 | return typeof node === "string" ? node : node.children.map(text).join(""); | |
| 205 | } | |
| 206 | ||
| 207 | function button(label: string) { | |
| 208 | const found = renderer!.root.findAllByType("button").find((node) => text(node) === label); | |
| 209 | if (!found) throw new Error(`Missing button: ${label}`); | |
| 210 | return found; | |
| 211 | } | |
| 212 | ||
| 213 | async function click(label: string) { | |
| 214 | const target = button(label); | |
| 215 | expect(target.props.disabled).not.toBe(true); | |
| 216 | await act(async () => target.props.onClick()); | |
| 217 | } | |
| 218 | ||
| 219 | async function mountImport(remote = false) { | |
| 220 | state.selectedEnvironment = remote ? "remote" : "primary"; | |
| 221 | await act(async () => { | |
| 222 | renderer = create( | |
| 223 | <RegistryContext.Provider value={state.registry!}> | |
| 224 | <WelcomeWizard localAvailable={!remote} onDone={state.onDone} /> | |
| 225 | </RegistryContext.Provider>, | |
| 226 | ); | |
| 227 | }); | |
| 228 | await click("Continue"); | |
| 229 | await click("Continue"); | |
| 230 | expect(text(renderer!.root)).toContain("Choose your projects"); | |
| 231 | } | |
| 232 | ||
| 233 | beforeEach(() => { | |
| 234 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 235 | vi.stubGlobal("document", { activeElement: null, body: {}, getElementById: () => null }); | |
| 236 | state.registry = AtomRegistry.make(); | |
| 237 | for (const id of [primaryId, remoteId]) { | |
| 238 | state.sessions.set( | |
| 239 | id, | |
| 240 | Atom.make<SessionResult>(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe( | |
| 241 | Atom.keepAlive, | |
| 242 | ), | |
| 243 | ); | |
| 244 | } | |
| 245 | state.projects = Atom.make<ReadonlyArray<Project>>([]).pipe(Atom.keepAlive); | |
| 246 | state.providers = Atom.make<ReadonlyArray<never>>([]).pipe(Atom.keepAlive); | |
| 247 | state.config = Atom.make(null).pipe(Atom.keepAlive); | |
| 248 | state.candidates = [candidate("/projects/first"), candidate("/projects/second")]; | |
| 249 | state.createProject | |
| 250 | .mockReset() | |
| 251 | .mockImplementation(async (input: CreateProjectInput) => recordProject(input)); | |
| 252 | state.importThreads | |
| 253 | .mockReset() | |
| 254 | .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 })); | |
| 255 | state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 256 | state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId)); | |
| 257 | state.completeOnboarding.mockReset().mockResolvedValue(undefined); | |
| 258 | state.onDone.mockReset(); | |
| 259 | state.scan.mockReset(); | |
| 260 | state.refreshScan.mockReset(); | |
| 261 | }); | |
| 262 | ||
| 263 | afterEach(async () => { | |
| 264 | await act(async () => renderer?.unmount()); | |
| 265 | renderer = undefined; | |
| 266 | state.registry?.dispose(); | |
| 267 | state.sessions.clear(); | |
| 268 | vi.unstubAllGlobals(); | |
| 269 | }); | |
| 270 | ||
| 271 | it("keeps scanning, choosing and skipping available to a paired read-only environment", async () => { | |
| 272 | setGrant(remoteId, false); | |
| 273 | await mountImport(true); | |
| 274 | expect(state.scan).toHaveBeenCalledWith(expect.objectContaining({ environmentId: remoteId })); | |
| 275 | expect(text(renderer!.root)).toContain("/projects/first"); | |
| 276 | expect(button("Import 2 projects").props.disabled).toBe(true); | |
| 277 | expect(text(renderer!.root)).toContain(permissionMessage); | |
| 278 | await click("Do not import projects"); | |
| 279 | expect(state.createProject).not.toHaveBeenCalled(); | |
| 280 | expect(state.importThreads).not.toHaveBeenCalled(); | |
| 281 | expect(state.completeOnboarding).toHaveBeenCalledOnce(); | |
| 282 | expect(state.onDone).toHaveBeenCalledOnce(); | |
| 283 | }); | |
| 284 | ||
| 285 | it("waits for the selected grant and enables import when it arrives", async () => { | |
| 286 | state.registry!.set(state.sessions.get(primaryId)!, AsyncResult.initial()); | |
| 287 | await mountImport(); | |
| 288 | expect(button("Import 2 projects").props.disabled).toBe(true); | |
| 289 | await act(async () => setGrant(primaryId, true)); | |
| 290 | await click("Import 2 projects"); | |
| 291 | expect(state.createProject).toHaveBeenCalledTimes(2); | |
| 292 | expect(state.importThreads).toHaveBeenCalledTimes(2); | |
| 293 | expect(state.onDone).toHaveBeenCalledWith(expect.objectContaining({ environmentId: primaryId })); | |
| 294 | }); | |
| 295 | ||
| 296 | it("preserves selections and denies a retained action using the paired environment's fresh grant", async () => { | |
| 297 | await mountImport(true); | |
| 298 | await act(async () => { | |
| 299 | renderer!.root.findAllByType("input")[1]!.props.onCheckedChange(false); | |
| 300 | }); | |
| 301 | const retainedImport = button("Import 1 project").props.onClick; | |
| 302 | await act(async () => { | |
| 303 | setGrant(remoteId, false); | |
| 304 | retainedImport(); | |
| 305 | }); | |
| 306 | expect(button("Import 1 project").props.disabled).toBe(true); | |
| 307 | expect(renderer!.root.findAllByType("input")[1]!.props.checked).toBe(false); | |
| 308 | expect(text(renderer!.root)).toContain(permissionMessage); | |
| 309 | expect(state.createProject).not.toHaveBeenCalled(); | |
| 310 | expect(state.importThreads).not.toHaveBeenCalled(); | |
| 311 | expect(state.onDone).not.toHaveBeenCalled(); | |
| 312 | ||
| 313 | await act(async () => setGrant(remoteId, true)); | |
| 314 | expect(text(renderer!.root)).not.toContain(permissionMessage); | |
| 315 | await click("Import 1 project"); | |
| 316 | expect(state.createProject).toHaveBeenCalledExactlyOnceWith( | |
| 317 | expect.objectContaining({ environmentId: remoteId }), | |
| 318 | ); | |
| 319 | expect(state.importThreads).toHaveBeenCalledExactlyOnceWith( | |
| 320 | expect.objectContaining({ environmentId: remoteId }), | |
| 321 | ); | |
| 322 | }); | |
| 323 | ||
| 324 | it("rechecks after project creation and can retry its history without recreating the project", async () => { | |
| 325 | const creation = deferred<void>(); | |
| 326 | state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => { | |
| 327 | await creation.promise; | |
| 328 | return recordProject(input); | |
| 329 | }); | |
| 330 | await mountImport(); | |
| 331 | await click("Import 2 projects"); | |
| 332 | expect(state.createProject).toHaveBeenCalledOnce(); | |
| 333 | await act(async () => { | |
| 334 | setGrant(primaryId, false); | |
| 335 | creation.resolve(); | |
| 336 | await creation.promise; | |
| 337 | }); | |
| 338 | expect(state.importThreads).not.toHaveBeenCalled(); | |
| 339 | expect(state.createProject).toHaveBeenCalledOnce(); | |
| 340 | expect(state.onDone).not.toHaveBeenCalled(); | |
| 341 | expect(text(renderer!.root)).toContain(permissionMessage); | |
| 342 | ||
| 343 | await act(async () => setGrant(primaryId, true)); | |
| 344 | await click("Import 2 projects"); | |
| 345 | expect(state.createProject).toHaveBeenCalledTimes(2); | |
| 346 | expect(state.importThreads).toHaveBeenCalledTimes(2); | |
| 347 | expect(state.onDone).toHaveBeenCalledOnce(); | |
| 348 | }); | |
| 349 | ||
| 350 | it.each([false, true])( | |
| 351 | "rechecks after history import before the next project write (existing: %s)", | |
| 352 | async (existing) => { | |
| 353 | if (existing) { | |
| 354 | const projectId = ProjectId.make("second-existing"); | |
| 355 | state.candidates[1] = { ...state.candidates[1]!, projectId }; | |
| 356 | state.registry!.set(state.projects!, [ | |
| 357 | { id: projectId, environmentId: primaryId, workspaceRoot: "/projects/second" }, | |
| 358 | ]); | |
| 359 | } | |
| 360 | const imported = deferred<{ importedCount: number; skippedCount: number }>(); | |
| 361 | state.importThreads.mockImplementationOnce(async () => | |
| 362 | AsyncResult.success(await imported.promise), | |
| 363 | ); | |
| 364 | await mountImport(); | |
| 365 | await click("Import 2 projects"); | |
| 366 | expect(state.createProject).toHaveBeenCalledOnce(); | |
| 367 | expect(state.importThreads).toHaveBeenCalledOnce(); | |
| 368 | await act(async () => { | |
| 369 | setGrant(primaryId, false); | |
| 370 | imported.resolve({ importedCount: 1, skippedCount: 0 }); | |
| 371 | await imported.promise; | |
| 372 | }); | |
| 373 | expect(state.createProject).toHaveBeenCalledOnce(); | |
| 374 | expect(state.importThreads).toHaveBeenCalledOnce(); | |
| 375 | expect(state.onDone).not.toHaveBeenCalled(); | |
| 376 | expect(text(renderer!.root)).toContain(permissionMessage); | |
| 377 | ||
| 378 | await act(async () => setGrant(primaryId, true)); | |
| 379 | await click("Import 2 projects"); | |
| 380 | expect(state.createProject).toHaveBeenCalledTimes(existing ? 1 : 2); | |
| 381 | expect(state.importThreads).toHaveBeenCalledTimes(2); | |
| 382 | expect(state.onDone).toHaveBeenCalledOnce(); | |
| 383 | }, | |
| 384 | ); | |
| 385 | ||
| 386 | it("completes accepted imports when permission is revoked after the final server write", async () => { | |
| 387 | state.candidates = state.candidates.slice(0, 1); | |
| 388 | const imported = deferred<{ importedCount: number; skippedCount: number }>(); | |
| 389 | state.importThreads.mockImplementationOnce(async () => | |
| 390 | AsyncResult.success(await imported.promise), | |
| 391 | ); | |
| 392 | await mountImport(); | |
| 393 | await click("Import 1 project"); | |
| 394 | await act(async () => { | |
| 395 | setGrant(primaryId, false); | |
| 396 | imported.resolve({ importedCount: 1, skippedCount: 0 }); | |
| 397 | await imported.promise; | |
| 398 | }); | |
| 399 | expect(state.createProject).toHaveBeenCalledOnce(); | |
| 400 | expect(state.importThreads).toHaveBeenCalledOnce(); | |
| 401 | expect(state.completeOnboarding).toHaveBeenCalledOnce(); | |
| 402 | expect(state.onDone).toHaveBeenCalledOnce(); | |
| 403 | }); |
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+6| ... | ... | @@ -12,6 +12,12 @@ const mocks = vi.hoisted(() => ({ |
| 12 | 12 | toast: vi.fn(), |
| 13 | 13 | projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>, |
| 14 | 14 | })); |
| 15 | vi.mock("../../state/session", () => ({ | |
| 16 | useEnvironmentScope: () => true, | |
| 17 | useEnvironmentsWithScope: (environments: Array<{ environmentId: string }>) => | |
| 18 | new Set(environments.map((entry) => entry.environmentId)), | |
| 19 | readEnvironmentScope: () => true, | |
| 20 | })); | |
| 15 | 21 | vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" })); |
| 16 | 22 | vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } })); |
| 17 | 23 | vi.mock("../../state/use-atom-command", () => ({ |
apps/web/src/components/onboarding/WelcomeWizard.tsx+39-1| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | import { |
| 18 | 18 | CommandId, |
| 19 | 19 | defaultInstanceIdForDriver, |
| 20 | AuthOrchestrationOperateScope, | |
| 20 | 21 | ProviderDriverKind, |
| 21 | 22 | ThreadId, |
| 22 | 23 | } from "@t3tools/contracts"; |
| ... | ... | @@ -60,6 +61,7 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment |
| 60 | 61 | import { useProjectScans } from "../../onboarding/useProjectScans"; |
| 61 | 62 | import { projectEnvironment } from "../../state/projects"; |
| 62 | 63 | import { serverEnvironment } from "../../state/server"; |
| 64 | import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; | |
| 63 | 65 | import { terminalEnvironment } from "../../state/terminal"; |
| 64 | 66 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 65 | 67 | import { connectPairing } from "../../connection/onboarding"; |
| ... | ... | @@ -1145,6 +1147,8 @@ function AgentInstallTerminal({ |
| 1145 | 1147 | |
| 1146 | 1148 | // ── Step 4: import ─────────────────────────────────────────── |
| 1147 | 1149 | |
| 1150 | const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history."; | |
| 1151 | ||
| 1148 | 1152 | function ImportStep({ |
| 1149 | 1153 | scans, |
| 1150 | 1154 | isImporting, |
| ... | ... | @@ -1161,6 +1165,7 @@ function ImportStep({ |
| 1161 | 1165 | ) => Promise<boolean>; |
| 1162 | 1166 | }) { |
| 1163 | 1167 | const { environments } = useEnvironments(); |
| 1168 | const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope); | |
| 1164 | 1169 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); |
| 1165 | 1170 | const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); |
| 1166 | 1171 | const projects = useProjects(); |
| ... | ... | @@ -1222,6 +1227,16 @@ function ImportStep({ |
| 1222 | 1227 | ); |
| 1223 | 1228 | const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key)); |
| 1224 | 1229 | |
| 1230 | const canImport = selected.every((candidate) => | |
| 1231 | writableEnvironments.has(candidate.environmentId), | |
| 1232 | ); | |
| 1233 | const [importError, setImportError] = useState(""); | |
| 1234 | const visibleImportError = !canImport | |
| 1235 | ? IMPORT_PERMISSION_MESSAGE | |
| 1236 | : importError === IMPORT_PERMISSION_MESSAGE | |
| 1237 | ? "" | |
| 1238 | : importError; | |
| 1239 | ||
| 1225 | 1240 | const finishAfterImport = () => { |
| 1226 | 1241 | const projectRef = resolveOnboardingLandingProject( |
| 1227 | 1242 | lastImportSelectionRef.current, |
| ... | ... | @@ -1238,6 +1253,18 @@ function ImportStep({ |
| 1238 | 1253 | |
| 1239 | 1254 | const runImport = async (selection: typeof candidates) => { |
| 1240 | 1255 | if (isImporting) return; |
| 1256 | const hasAccess = () => | |
| 1257 | selection.every((candidate) => | |
| 1258 | readEnvironmentScope(candidate.environmentId, AuthOrchestrationOperateScope), | |
| 1259 | ); | |
| 1260 | const stopForDeniedAccess = () => { | |
| 1261 | setIsImporting(false); | |
| 1262 | setImportError(IMPORT_PERMISSION_MESSAGE); | |
| 1263 | }; | |
| 1264 | if (!hasAccess()) { | |
| 1265 | stopForDeniedAccess(); | |
| 1266 | return; | |
| 1267 | } | |
| 1241 | 1268 | if (selection.length === 0) { |
| 1242 | 1269 | void onDone(); |
| 1243 | 1270 | return; |
| ... | ... | @@ -1269,6 +1296,10 @@ function ImportStep({ |
| 1269 | 1296 | ) { |
| 1270 | 1297 | return; |
| 1271 | 1298 | } |
| 1299 | if (!hasAccess()) { | |
| 1300 | stopForDeniedAccess(); | |
| 1301 | return; | |
| 1302 | } | |
| 1272 | 1303 | if (importedProjects.has(candidate.key)) continue; |
| 1273 | 1304 | let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); |
| 1274 | 1305 | if (projectId === null) { |
| ... | ... | @@ -1308,6 +1339,10 @@ function ImportStep({ |
| 1308 | 1339 | } |
| 1309 | 1340 | } |
| 1310 | 1341 | |
| 1342 | if (!hasAccess()) { | |
| 1343 | stopForDeniedAccess(); | |
| 1344 | return; | |
| 1345 | } | |
| 1311 | 1346 | const threadImportResult = await importThreads({ |
| 1312 | 1347 | environmentId, |
| 1313 | 1348 | input: { projectId, expectedWorkspaceRoot: candidate.path }, |
| ... | ... | @@ -1457,13 +1492,16 @@ function ImportStep({ |
| 1457 | 1492 | })} |
| 1458 | 1493 | </div> |
| 1459 | 1494 | </ScrollArea> |
| 1495 | {visibleImportError ? ( | |
| 1496 | <p className="mt-3 text-sm text-destructive">{visibleImportError}</p> | |
| 1497 | ) : null} | |
| 1460 | 1498 | <div className="mt-6 flex flex-wrap items-center justify-end gap-3"> |
| 1461 | 1499 | <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}> |
| 1462 | 1500 | Do not import projects |
| 1463 | 1501 | </Button> |
| 1464 | 1502 | <Button |
| 1465 | 1503 | autoFocus |
| 1466 | disabled={isImporting || selected.length === 0} | |
| 1504 | disabled={!canImport || isImporting || selected.length === 0} | |
| 1467 | 1505 | onClick={() => void runImport(selected)} |
| 1468 | 1506 | > |
| 1469 | 1507 | {isImporting |
apps/web/src/components/projectScriptEditor.permissions.test.tsx created+340| ... | ... | @@ -0,0 +1,340 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthSettingsWriteScope, | |
| 4 | EnvironmentId, | |
| 5 | type AuthEnvironmentScope, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import { AsyncResult } from "effect/reactivity"; | |
| 9 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | const permissions = vi.hoisted(() => ({ canWriteSettings: false, canEditProject: true })); | |
| 13 | ||
| 14 | vi.mock("~/state/session", () => ({ | |
| 15 | readEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) => | |
| 16 | scope === AuthOrchestrationOperateScope | |
| 17 | ? permissions.canEditProject | |
| 18 | : permissions.canWriteSettings, | |
| 19 | useEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) => | |
| 20 | scope === AuthOrchestrationOperateScope | |
| 21 | ? permissions.canEditProject | |
| 22 | : permissions.canWriteSettings, | |
| 23 | })); | |
| 24 | vi.mock("./ui/dialog", () => ({ | |
| 25 | Dialog: "div", | |
| 26 | DialogDescription: "div", | |
| 27 | DialogFooter: "div", | |
| 28 | DialogHeader: "div", | |
| 29 | DialogPanel: "div", | |
| 30 | DialogPopup: "div", | |
| 31 | DialogTitle: "div", | |
| 32 | })); | |
| 33 | vi.mock("./ui/alert-dialog", () => ({ | |
| 34 | AlertDialog: "div", | |
| 35 | AlertDialogClose: "button", | |
| 36 | AlertDialogDescription: "div", | |
| 37 | AlertDialogFooter: "div", | |
| 38 | AlertDialogHeader: "div", | |
| 39 | AlertDialogPopup: "div", | |
| 40 | AlertDialogTitle: "div", | |
| 41 | })); | |
| 42 | vi.mock("./ui/popover", () => ({ | |
| 43 | Popover: "div", | |
| 44 | PopoverPopup: "div", | |
| 45 | PopoverTrigger: "button", | |
| 46 | })); | |
| 47 | vi.mock("./ui/button", () => ({ Button: "button" })); | |
| 48 | vi.mock("./ui/input", () => ({ Input: "input" })); | |
| 49 | vi.mock("./ui/label", () => ({ Label: "label" })); | |
| 50 | vi.mock("./ui/switch", () => ({ Switch: "input" })); | |
| 51 | vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); | |
| 52 | ||
| 53 | import { | |
| 54 | ProjectScriptEditorDialog, | |
| 55 | type NewProjectScriptInput, | |
| 56 | type ProjectScriptEditorRequest, | |
| 57 | } from "./projectScriptEditor"; | |
| 58 | ||
| 59 | const request: ProjectScriptEditorRequest = { | |
| 60 | scriptId: "test", | |
| 61 | initial: { | |
| 62 | name: "Test", | |
| 63 | command: "vp test", | |
| 64 | icon: "test", | |
| 65 | runOnWorktreeCreate: false, | |
| 66 | waitForSetup: false, | |
| 67 | keybinding: "mod+k", | |
| 68 | previewUrl: null, | |
| 69 | autoOpenPreview: false, | |
| 70 | }, | |
| 71 | }; | |
| 72 | ||
| 73 | describe("ProjectScriptEditorDialog", () => { | |
| 74 | let renderer: ReactTestRenderer | undefined; | |
| 75 | ||
| 76 | beforeEach(() => { | |
| 77 | permissions.canWriteSettings = false; | |
| 78 | permissions.canEditProject = true; | |
| 79 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 80 | }); | |
| 81 | ||
| 82 | afterEach(async () => { | |
| 83 | await act(() => renderer?.unmount()); | |
| 84 | vi.unstubAllGlobals(); | |
| 85 | }); | |
| 86 | ||
| 87 | async function openEditor( | |
| 88 | onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)), | |
| 89 | editorRequest = request, | |
| 90 | actions = { onDelete: vi.fn(), onClose: vi.fn() }, | |
| 91 | editScope: | |
| 92 | | typeof AuthOrchestrationOperateScope | |
| 93 | | typeof AuthSettingsWriteScope = AuthOrchestrationOperateScope, | |
| 94 | ) { | |
| 95 | await act(() => { | |
| 96 | renderer = create( | |
| 97 | <ProjectScriptEditorDialog | |
| 98 | environmentId={EnvironmentId.make("script-editor-test")} | |
| 99 | editScope={editScope} | |
| 100 | request={editorRequest} | |
| 101 | scripts={[]} | |
| 102 | onSubmit={onSubmit} | |
| 103 | onDelete={actions.onDelete} | |
| 104 | onClose={actions.onClose} | |
| 105 | />, | |
| 106 | ); | |
| 107 | }); | |
| 108 | return renderer!.root; | |
| 109 | } | |
| 110 | ||
| 111 | it.each([null, "test"])( | |
| 112 | "saves a settings action without task access (id: %s)", | |
| 113 | async (scriptId) => { | |
| 114 | permissions.canEditProject = false; | |
| 115 | permissions.canWriteSettings = true; | |
| 116 | const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); | |
| 117 | const root = await openEditor( | |
| 118 | onSubmit, | |
| 119 | { ...request, scriptId }, | |
| 120 | undefined, | |
| 121 | AuthSettingsWriteScope, | |
| 122 | ); | |
| 123 | const submit = root.findByType("form").props.onSubmit; | |
| 124 | permissions.canWriteSettings = false; | |
| 125 | await act(async () => { | |
| 126 | await submit({ preventDefault() {} }); | |
| 127 | }); | |
| 128 | expect(onSubmit).not.toHaveBeenCalled(); | |
| 129 | permissions.canWriteSettings = true; | |
| 130 | await act(async () => { | |
| 131 | await submit({ preventDefault() {} }); | |
| 132 | }); | |
| 133 | expect(onSubmit).toHaveBeenCalledExactlyOnceWith( | |
| 134 | scriptId, | |
| 135 | expect.objectContaining({ command: "vp test" }), | |
| 136 | ); | |
| 137 | }, | |
| 138 | ); | |
| 139 | ||
| 140 | it("rechecks settings access before deleting a settings action", async () => { | |
| 141 | permissions.canEditProject = false; | |
| 142 | permissions.canWriteSettings = true; | |
| 143 | const actions = { onDelete: vi.fn(), onClose: vi.fn() }; | |
| 144 | const root = await openEditor(undefined, request, actions, AuthSettingsWriteScope); | |
| 145 | await act(() => { | |
| 146 | root.findByProps({ variant: "destructive-outline" }).props.onClick(); | |
| 147 | }); | |
| 148 | const confirmDelete = root.findByProps({ variant: "destructive" }).props.onClick; | |
| 149 | permissions.canWriteSettings = false; | |
| 150 | await act(() => confirmDelete()); | |
| 151 | expect(actions.onDelete).not.toHaveBeenCalled(); | |
| 152 | permissions.canWriteSettings = true; | |
| 153 | await act(() => confirmDelete()); | |
| 154 | expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId); | |
| 155 | }); | |
| 156 | ||
| 157 | it("clears an old shortcut when adding an action with no shortcut", async () => { | |
| 158 | permissions.canWriteSettings = true; | |
| 159 | let persistedKeybinding: string | null = "mod+k"; | |
| 160 | const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { | |
| 161 | if (input.keybinding !== undefined) persistedKeybinding = input.keybinding; | |
| 162 | return AsyncResult.success(undefined); | |
| 163 | }); | |
| 164 | const root = await openEditor(onSubmit, { | |
| 165 | scriptId: null, | |
| 166 | initial: { ...request.initial, keybinding: null }, | |
| 167 | }); | |
| 168 | ||
| 169 | await act(async () => { | |
| 170 | await root.findByType("form").props.onSubmit({ preventDefault() {} }); | |
| 171 | }); | |
| 172 | ||
| 173 | expect(persistedKeybinding).toBeNull(); | |
| 174 | expect(onSubmit).toHaveBeenCalledWith(null, expect.objectContaining({ keybinding: null })); | |
| 175 | }); | |
| 176 | ||
| 177 | it.each([false, true])( | |
| 178 | "re-adds a deleted action while preserving its shortcut without settings access (revoked: %s)", | |
| 179 | async (revokeBeforeSubmit) => { | |
| 180 | permissions.canWriteSettings = revokeBeforeSubmit; | |
| 181 | const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; | |
| 182 | const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { | |
| 183 | if (input.keybinding !== undefined && !permissions.canWriteSettings) { | |
| 184 | return AsyncResult.failure( | |
| 185 | Cause.fail(new Error("This connection cannot change keyboard shortcuts.")), | |
| 186 | ); | |
| 187 | } | |
| 188 | persisted.name = input.name; | |
| 189 | if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; | |
| 190 | return AsyncResult.success(undefined); | |
| 191 | }); | |
| 192 | const root = await openEditor(onSubmit, { | |
| 193 | scriptId: null, | |
| 194 | initial: { ...request.initial, keybinding: null }, | |
| 195 | }); | |
| 196 | ||
| 197 | permissions.canWriteSettings = false; | |
| 198 | await act(async () => { | |
| 199 | await root.findByType("form").props.onSubmit({ preventDefault() {} }); | |
| 200 | }); | |
| 201 | ||
| 202 | expect(persisted).toEqual({ name: "Test", keybinding: "mod+k" }); | |
| 203 | expect(onSubmit).toHaveBeenCalledOnce(); | |
| 204 | }, | |
| 205 | ); | |
| 206 | ||
| 207 | it.each([false, true])( | |
| 208 | "requires settings access to override a retained shortcut when re-adding an action (revoked: %s)", | |
| 209 | async (revokeBeforeSubmit) => { | |
| 210 | permissions.canWriteSettings = true; | |
| 211 | vi.stubGlobal("navigator", { platform: "Linux" }); | |
| 212 | const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; | |
| 213 | const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { | |
| 214 | persisted.name = input.name; | |
| 215 | if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; | |
| 216 | return AsyncResult.success(undefined); | |
| 217 | }); | |
| 218 | const root = await openEditor(onSubmit, { | |
| 219 | scriptId: null, | |
| 220 | initial: { ...request.initial, keybinding: null }, | |
| 221 | }); | |
| 222 | await act(() => { | |
| 223 | root.findByProps({ id: "script-keybinding" }).props.onKeyDown({ | |
| 224 | key: "j", | |
| 225 | ctrlKey: true, | |
| 226 | metaKey: false, | |
| 227 | altKey: false, | |
| 228 | shiftKey: false, | |
| 229 | preventDefault() {}, | |
| 230 | }); | |
| 231 | }); | |
| 232 | if (revokeBeforeSubmit) permissions.canWriteSettings = false; | |
| 233 | await act(async () => { | |
| 234 | await root.findByType("form").props.onSubmit({ preventDefault() {} }); | |
| 235 | }); | |
| 236 | ||
| 237 | expect(persisted).toEqual( | |
| 238 | revokeBeforeSubmit | |
| 239 | ? { name: null, keybinding: "mod+k" } | |
| 240 | : { name: "Test", keybinding: "mod+j" }, | |
| 241 | ); | |
| 242 | }, | |
| 243 | ); | |
| 244 | ||
| 245 | it.each([false, true])( | |
| 246 | "preserves a concurrent shortcut change when saving only the script (settings access: %s)", | |
| 247 | async (canWriteSettings) => { | |
| 248 | permissions.canWriteSettings = canWriteSettings; | |
| 249 | const persisted = { name: request.initial.name, keybinding: request.initial.keybinding }; | |
| 250 | const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { | |
| 251 | persisted.name = input.name; | |
| 252 | if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; | |
| 253 | return AsyncResult.success(undefined); | |
| 254 | }); | |
| 255 | const root = await openEditor(onSubmit); | |
| 256 | ||
| 257 | // A second client changes the shortcut after this dialog snapshots it. | |
| 258 | persisted.keybinding = "mod+j"; | |
| 259 | await act(() => { | |
| 260 | root.findByProps({ id: "script-name" }).props.onChange({ target: { value: "Renamed" } }); | |
| 261 | }); | |
| 262 | await act(async () => { | |
| 263 | await root.findByType("form").props.onSubmit({ preventDefault() {} }); | |
| 264 | }); | |
| 265 | ||
| 266 | expect(persisted).toEqual({ name: "Renamed", keybinding: "mod+j" }); | |
| 267 | expect(onSubmit).toHaveBeenCalledOnce(); | |
| 268 | }, | |
| 269 | ); | |
| 270 | ||
| 271 | it.each([false, true])( | |
| 272 | "enforces settings access when submitting an explicit shortcut clear (revoked: %s)", | |
| 273 | async (revokeBeforeSubmit) => { | |
| 274 | permissions.canWriteSettings = true; | |
| 275 | const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); | |
| 276 | const root = await openEditor(onSubmit); | |
| 277 | await act(() => { | |
| 278 | root.findByProps({ id: "script-keybinding" }).props.onKeyDown({ | |
| 279 | key: "Backspace", | |
| 280 | preventDefault() {}, | |
| 281 | }); | |
| 282 | }); | |
| 283 | if (revokeBeforeSubmit) permissions.canWriteSettings = false; | |
| 284 | await act(async () => { | |
| 285 | await root.findByType("form").props.onSubmit({ preventDefault() {} }); | |
| 286 | }); | |
| 287 | ||
| 288 | if (revokeBeforeSubmit) { | |
| 289 | expect(onSubmit).not.toHaveBeenCalled(); | |
| 290 | } else { | |
| 291 | expect(onSubmit).toHaveBeenCalledWith( | |
| 292 | "test", | |
| 293 | expect.objectContaining({ keybinding: null }), | |
| 294 | ); | |
| 295 | } | |
| 296 | }, | |
| 297 | ); | |
| 298 | ||
| 299 | it.each([false, true])( | |
| 300 | "requires project access to save even with settings access (revoked: %s)", | |
| 301 | async (revokeBeforeSubmit) => { | |
| 302 | permissions.canEditProject = revokeBeforeSubmit; | |
| 303 | permissions.canWriteSettings = true; | |
| 304 | const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); | |
| 305 | const root = await openEditor(onSubmit); | |
| 306 | const submit = root.findByType("form").props.onSubmit; | |
| 307 | ||
| 308 | permissions.canEditProject = false; | |
| 309 | await act(async () => { | |
| 310 | await submit({ preventDefault() {} }); | |
| 311 | }); | |
| 312 | expect(onSubmit).not.toHaveBeenCalled(); | |
| 313 | ||
| 314 | permissions.canEditProject = true; | |
| 315 | await act(async () => { | |
| 316 | await submit({ preventDefault() {} }); | |
| 317 | }); | |
| 318 | expect(onSubmit).toHaveBeenCalledOnce(); | |
| 319 | }, | |
| 320 | ); | |
| 321 | ||
| 322 | it("rechecks project access before deleting from an already open confirmation", async () => { | |
| 323 | const actions = { onDelete: vi.fn(), onClose: vi.fn() }; | |
| 324 | const root = await openEditor(undefined, request, actions); | |
| 325 | await act(() => { | |
| 326 | root.findByProps({ variant: "destructive-outline" }).props.onClick(); | |
| 327 | }); | |
| 328 | const confirmDelete = root.findByProps({ variant: "destructive" }).props.onClick; | |
| 329 | ||
| 330 | permissions.canEditProject = false; | |
| 331 | await act(() => confirmDelete()); | |
| 332 | expect(actions.onDelete).not.toHaveBeenCalled(); | |
| 333 | expect(actions.onClose).not.toHaveBeenCalled(); | |
| 334 | ||
| 335 | permissions.canEditProject = true; | |
| 336 | await act(() => confirmDelete()); | |
| 337 | expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId); | |
| 338 | expect(actions.onClose).toHaveBeenCalledOnce(); | |
| 339 | }); | |
| 340 | }); |
apps/web/src/components/projectScriptEditor.test.tsx+6| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | 2 | import * as Cause from "effect/Cause"; |
| 2 | 3 | import { AsyncResult } from "effect/reactivity"; |
| 3 | 4 | import { act, StrictMode, type ReactNode } from "react"; |
| 4 | 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | 7 | |
| 8 | vi.mock("~/state/session", () => ({ | |
| 9 | readEnvironmentScope: () => true, | |
| 10 | useEnvironmentScope: () => true, | |
| 11 | })); | |
| 7 | 12 | vi.mock("./ui/dialog", () => ({ |
| 8 | 13 | Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null), |
| 9 | 14 | DialogDescription: "p", |
| ... | ... | @@ -58,6 +63,7 @@ function editor(nextRequest: ProjectScriptEditorRequest) { |
| 58 | 63 | return ( |
| 59 | 64 | <StrictMode> |
| 60 | 65 | <ProjectScriptEditorDialog |
| 66 | environmentId={EnvironmentId.make("test")} | |
| 61 | 67 | request={nextRequest} |
| 62 | 68 | scripts={[]} |
| 63 | 69 | onSubmit={onSubmit} |
apps/web/src/components/projectScriptEditor.tsx+46-14| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | import type { | |
| 2 | ProjectScript, | |
| 3 | ProjectScriptIcon, | |
| 4 | ResolvedKeybindingsConfig, | |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthSettingsWriteScope, | |
| 4 | type EnvironmentId, | |
| 5 | type ProjectScript, | |
| 6 | type ProjectScriptIcon, | |
| 7 | type ResolvedKeybindingsConfig, | |
| 5 | 8 | } from "@t3tools/contracts"; |
| 6 | 9 | import { |
| 7 | 10 | isAtomCommandInterrupted, |
| ... | ... | @@ -31,6 +34,8 @@ import { |
| 31 | 34 | } from "~/lib/projectScriptKeybindings"; |
| 32 | 35 | import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; |
| 33 | 36 | import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; |
| 37 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 38 | import { useComposerMenuState } from "./chat/useComposerMenuState"; | |
| 34 | 39 | import { |
| 35 | 40 | AlertDialog, |
| 36 | 41 | AlertDialogClose, |
| ... | ... | @@ -87,7 +92,8 @@ export interface NewProjectScriptInput { |
| 87 | 92 | runOnWorktreeCreate: boolean; |
| 88 | 93 | /** Setup scripts only: hold the agent until the script exits. */ |
| 89 | 94 | waitForSetup: boolean; |
| 90 | keybinding: string | null; | |
| 95 | /** Omit to preserve the current shortcut when the form did not edit it. */ | |
| 96 | keybinding?: string | null; | |
| 91 | 97 | /** Optional URL to open in the in-app preview when this script runs. */ |
| 92 | 98 | previewUrl: string | null; |
| 93 | 99 | /** When true, automatically open the preview panel pointed at `previewUrl`. */ |
| ... | ... | @@ -140,12 +146,16 @@ export function editorRequestForScript( |
| 140 | 146 | * being edited via `request`; the dialog owns the form state and validation. |
| 141 | 147 | */ |
| 142 | 148 | export function ProjectScriptEditorDialog({ |
| 149 | environmentId, | |
| 150 | editScope = AuthOrchestrationOperateScope, | |
| 143 | 151 | request, |
| 144 | 152 | scripts, |
| 145 | 153 | onSubmit, |
| 146 | 154 | onDelete, |
| 147 | 155 | onClose, |
| 148 | 156 | }: { |
| 157 | environmentId: EnvironmentId; | |
| 158 | editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope; | |
| 149 | 159 | request: ProjectScriptEditorRequest | null; |
| 150 | 160 | /** Existing scripts, used to derive a unique id for new scripts. */ |
| 151 | 161 | scripts: ReadonlyArray<ProjectScript>; |
| ... | ... | @@ -156,18 +166,20 @@ export function ProjectScriptEditorDialog({ |
| 156 | 166 | onDelete: (scriptId: string) => void; |
| 157 | 167 | onClose: () => void; |
| 158 | 168 | }) { |
| 169 | const canEditActions = useEnvironmentScope(environmentId, editScope); | |
| 170 | const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); | |
| 159 | 171 | const formId = React.useId(); |
| 160 | 172 | const [name, setName] = useState(""); |
| 161 | 173 | const [command, setCommand] = useState(""); |
| 162 | 174 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); |
| 163 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 175 | const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions); | |
| 164 | 176 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 165 | 177 | const [waitForSetup, setWaitForSetup] = useState(false); |
| 166 | 178 | const [keybinding, setKeybinding] = useState(""); |
| 167 | 179 | const [previewUrl, setPreviewUrl] = useState(""); |
| 168 | 180 | const [autoOpenPreview, setAutoOpenPreview] = useState(false); |
| 169 | 181 | const [validationError, setValidationError] = useState<string | null>(null); |
| 170 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |
| 182 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useComposerMenuState(!canEditActions); | |
| 171 | 183 | const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| 172 | 184 | const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null); |
| 173 | 185 | |
| ... | ... | @@ -199,7 +211,7 @@ export function ProjectScriptEditorDialog({ |
| 199 | 211 | setAutoOpenPreview(request.initial.autoOpenPreview); |
| 200 | 212 | setValidationError(request.error ?? null); |
| 201 | 213 | setSavingRequest(null); |
| 202 | }, [request]); | |
| 214 | }, [request, setIconPickerOpen]); | |
| 203 | 215 | |
| 204 | 216 | const close = () => { |
| 205 | 217 | pendingSubmissionRef.current = null; |
| ... | ... | @@ -209,6 +221,8 @@ export function ProjectScriptEditorDialog({ |
| 209 | 221 | }; |
| 210 | 222 | |
| 211 | 223 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { |
| 224 | if (!readEnvironmentScope(environmentId, editScope)) return; | |
| 225 | if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return; | |
| 212 | 226 | if (event.key === "Tab") return; |
| 213 | 227 | event.preventDefault(); |
| 214 | 228 | if (event.key === "Backspace" || event.key === "Delete") { |
| ... | ... | @@ -223,6 +237,17 @@ export function ProjectScriptEditorDialog({ |
| 223 | 237 | const submit = async (event: FormEvent) => { |
| 224 | 238 | event.preventDefault(); |
| 225 | 239 | if (!request || pendingSubmissionRef.current !== null) return; |
| 240 | if (!readEnvironmentScope(environmentId, editScope)) { | |
| 241 | setValidationError("This connection cannot change project actions."); | |
| 242 | return; | |
| 243 | } | |
| 244 | const changesKeybinding = | |
| 245 | (keybinding.trim() || null) !== (request.initial.keybinding?.trim() || null); | |
| 246 | const canChangeKeybinding = readEnvironmentScope(environmentId, AuthSettingsWriteScope); | |
| 247 | if (changesKeybinding && !canChangeKeybinding) { | |
| 248 | setValidationError("This connection cannot change keyboard shortcuts."); | |
| 249 | return; | |
| 250 | } | |
| 226 | 251 | const trimmedName = name.trim(); |
| 227 | 252 | const trimmedCommand = command.trim(); |
| 228 | 253 | if (trimmedName.length === 0) { |
| ... | ... | @@ -254,7 +279,9 @@ export function ProjectScriptEditorDialog({ |
| 254 | 279 | icon, |
| 255 | 280 | runOnWorktreeCreate, |
| 256 | 281 | waitForSetup: runOnWorktreeCreate && waitForSetup, |
| 257 | keybinding: keybindingRule?.key ?? null, | |
| 282 | ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding | |
| 283 | ? { keybinding: keybindingRule?.key ?? null } | |
| 284 | : {}), | |
| 258 | 285 | previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, |
| 259 | 286 | autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, |
| 260 | 287 | } satisfies NewProjectScriptInput; |
| ... | ... | @@ -309,7 +336,7 @@ export function ProjectScriptEditorDialog({ |
| 309 | 336 | </DialogHeader> |
| 310 | 337 | <DialogPanel> |
| 311 | 338 | <form id={formId} onSubmit={submit}> |
| 312 | <fieldset className="space-y-4" disabled={isSaving}> | |
| 339 | <fieldset className="space-y-4" disabled={isSaving || !canEditActions}> | |
| 313 | 340 | <div className="space-y-1.5"> |
| 314 | 341 | <Label htmlFor="script-name">Name</Label> |
| 315 | 342 | <div className="flex items-center gap-2"> |
| ... | ... | @@ -364,6 +391,7 @@ export function ProjectScriptEditorDialog({ |
| 364 | 391 | <div className="space-y-1.5"> |
| 365 | 392 | <Label htmlFor="script-keybinding">Keybinding</Label> |
| 366 | 393 | <Input |
| 394 | disabled={!canWriteSettings} | |
| 367 | 395 | id="script-keybinding" |
| 368 | 396 | placeholder="Press shortcut" |
| 369 | 397 | value={keybinding} |
| ... | ... | @@ -437,7 +465,7 @@ export function ProjectScriptEditorDialog({ |
| 437 | 465 | type="button" |
| 438 | 466 | variant="destructive-outline" |
| 439 | 467 | className="mr-auto" |
| 440 | disabled={isSaving} | |
| 468 | disabled={isSaving || !canEditActions} | |
| 441 | 469 | onClick={() => setDeleteConfirmOpen(true)} |
| 442 | 470 | > |
| 443 | 471 | Delete |
| ... | ... | @@ -446,14 +474,14 @@ export function ProjectScriptEditorDialog({ |
| 446 | 474 | <Button type="button" variant="outline" onClick={close}> |
| 447 | 475 | Cancel |
| 448 | 476 | </Button> |
| 449 | <Button form={formId} type="submit" disabled={isSaving}> | |
| 477 | <Button form={formId} type="submit" disabled={isSaving || !canEditActions}> | |
| 450 | 478 | {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"} |
| 451 | 479 | </Button> |
| 452 | 480 | </DialogFooter> |
| 453 | 481 | </DialogPopup> |
| 454 | 482 | </Dialog> |
| 455 | 483 | |
| 456 | <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> | |
| 484 | <AlertDialog open={deleteConfirmOpen && canEditActions} onOpenChange={setDeleteConfirmOpen}> | |
| 457 | 485 | <AlertDialogPopup> |
| 458 | 486 | <AlertDialogHeader> |
| 459 | 487 | <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle> |
| ... | ... | @@ -463,9 +491,13 @@ export function ProjectScriptEditorDialog({ |
| 463 | 491 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 464 | 492 | <Button |
| 465 | 493 | variant="destructive" |
| 466 | disabled={isSaving} | |
| 494 | disabled={isSaving || !canEditActions} | |
| 467 | 495 | onClick={() => { |
| 468 | 496 | if (!request?.scriptId) return; |
| 497 | if (!readEnvironmentScope(environmentId, editScope)) { | |
| 498 | setValidationError("This connection cannot change project actions."); | |
| 499 | return; | |
| 500 | } | |
| 469 | 501 | setDeleteConfirmOpen(false); |
| 470 | 502 | close(); |
| 471 | 503 | onDelete(request.scriptId); |
apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx+17| ... | ... | @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ |
| 14 | 14 | result: null as { readonly agents: ReadonlyArray<AcpRegistrySearchAgent> } | null, |
| 15 | 15 | error: null as string | null, |
| 16 | 16 | isPending: false, |
| 17 | canManage: true, | |
| 17 | 18 | refresh: vi.fn(), |
| 18 | 19 | search: vi.fn(() => atoms.search), |
| 19 | 20 | prepare: vi.fn(), |
| ... | ... | @@ -53,6 +54,11 @@ vi.mock("../../state/server", () => ({ |
| 53 | 54 | }, |
| 54 | 55 | })); |
| 55 | 56 | |
| 57 | vi.mock("../../state/session", () => ({ | |
| 58 | useEnvironmentScope: () => state.canManage, | |
| 59 | readEnvironmentScope: () => state.canManage, | |
| 60 | })); | |
| 61 | ||
| 56 | 62 | vi.mock("../../state/query", () => ({ |
| 57 | 63 | useEnvironmentQuery: () => ({ |
| 58 | 64 | data: state.result, |
| ... | ... | @@ -119,6 +125,7 @@ function findByAriaLabel( |
| 119 | 125 | |
| 120 | 126 | describe("AcpRegistrySearchStep", () => { |
| 121 | 127 | beforeEach(() => { |
| 128 | state.canManage = true; | |
| 122 | 129 | vi.useFakeTimers(); |
| 123 | 130 | hooks.reset(); |
| 124 | 131 | state.result = null; |
| ... | ... | @@ -265,6 +272,16 @@ describe("AcpRegistrySearchStep", () => { |
| 265 | 272 | }); |
| 266 | 273 | }); |
| 267 | 274 | |
| 275 | it("rechecks the grant before preparing an agent", async () => { | |
| 276 | state.result = { agents: [gemini] }; | |
| 277 | const onPrepared = vi.fn(); | |
| 278 | const add = findByAriaLabel(render({ onPrepared }), "Add Gemini CLI"); | |
| 279 | state.canManage = false; | |
| 280 | await (add.props.onClick as () => void)(); | |
| 281 | expect(state.prepare).not.toHaveBeenCalled(); | |
| 282 | expect(onPrepared).not.toHaveBeenCalled(); | |
| 283 | }); | |
| 284 | ||
| 268 | 285 | it("ignores a stale prepare completion", async () => { |
| 269 | 286 | let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void; |
| 270 | 287 | let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void; |
apps/web/src/components/settings/AcpRegistrySearchStep.tsx+5-1| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthProvidersManageScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { |
| 2 | 4 | isAtomCommandInterrupted, |
| 3 | 5 | squashAtomCommandFailure, |
| ... | ... | @@ -64,6 +66,7 @@ export function AcpRegistrySearchStep({ |
| 64 | 66 | // An empty registry query is the compact compatible catalog. Start there so |
| 65 | 67 | // entering this step is useful before the user knows what to search for. |
| 66 | 68 | const [submittedQuery, setSubmittedQuery] = useState(""); |
| 69 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 67 | 70 | const [preparingId, setPreparingId] = useState<string | null>(null); |
| 68 | 71 | const [prepareError, setPrepareError] = useState<string | null>(null); |
| 69 | 72 | const prepareGeneration = useRef(0); |
| ... | ... | @@ -105,6 +108,7 @@ export function AcpRegistrySearchStep({ |
| 105 | 108 | }; |
| 106 | 109 | |
| 107 | 110 | const handlePrepare = async (agent: AcpRegistrySearchAgent) => { |
| 111 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 108 | 112 | const generation = ++prepareGeneration.current; |
| 109 | 113 | setPrepareError(null); |
| 110 | 114 | setPreparingId(agent.id); |
| ... | ... | @@ -271,7 +275,7 @@ export function AcpRegistrySearchStep({ |
| 271 | 275 | ) : null} |
| 272 | 276 | <Button |
| 273 | 277 | aria-label={`${alreadyAdded ? "Already added" : isPreparing ? progressLabel : "Add"} ${agent.name}`} |
| 274 | disabled={alreadyAdded || preparingId !== null} | |
| 278 | disabled={!canManageProviders || alreadyAdded || preparingId !== null} | |
| 275 | 279 | onClick={() => void handlePrepare(agent)} |
| 276 | 280 | size="xs" |
| 277 | 281 | variant={isPreparing ? "secondary" : "outline"} |
apps/web/src/components/settings/AcpSessionManagementSection.test.tsx+86-49| ... | ... | @@ -32,6 +32,12 @@ const commands = vi.hoisted(() => ({ |
| 32 | 32 | logout: vi.fn(), |
| 33 | 33 | })); |
| 34 | 34 | |
| 35 | const grants = vi.hoisted(() => ({ scopes: new Set<string>() })); | |
| 36 | vi.mock("~/state/session", async (original) => ({ | |
| 37 | ...(await original<typeof import("~/state/session")>()), | |
| 38 | useEnvironmentScope: (_id: unknown, scope: string) => grants.scopes.has(scope), | |
| 39 | readEnvironmentScope: (_id: unknown, scope: string) => grants.scopes.has(scope), | |
| 40 | })); | |
| 35 | 41 | const dialogs = vi.hoisted(() => ({ confirm: vi.fn() })); |
| 36 | 42 | |
| 37 | 43 | vi.mock("react", async (importOriginal) => { |
| ... | ... | @@ -105,14 +111,14 @@ const provider = { |
| 105 | 111 | skills: [], |
| 106 | 112 | } satisfies ServerProvider; |
| 107 | 113 | |
| 108 | function render(): ReactElement<Record<string, unknown>> { | |
| 114 | function render(readOnly = false): ReactElement<Record<string, unknown>> { | |
| 109 | 115 | hooks.beginRender(); |
| 110 | 116 | return AcpSessionManagementSection({ |
| 111 | 117 | environmentId, |
| 112 | 118 | instanceId, |
| 113 | 119 | provider, |
| 114 | 120 | projects: [{ id: projectId, title: "Antigravity", workspaceRoot: session.cwd }], |
| 115 | readOnly: false, | |
| 121 | readOnly, | |
| 116 | 122 | }) as ReactElement<Record<string, unknown>>; |
| 117 | 123 | } |
| 118 | 124 | |
| ... | ... | @@ -139,55 +145,56 @@ async function flushPromises(): Promise<void> { |
| 139 | 145 | await Promise.resolve(); |
| 140 | 146 | } |
| 141 | 147 | |
| 142 | describe("AcpSessionManagementSection", () => { | |
| 143 | beforeEach(() => { | |
| 144 | hooks.reset(); | |
| 145 | commands.list.mockReset().mockResolvedValue({ | |
| 146 | _tag: "Success", | |
| 147 | value: { | |
| 148 | sessions: [session], | |
| 149 | nextCursor: null, | |
| 150 | canLoad: true, | |
| 151 | canResume: true, | |
| 152 | canDelete: true, | |
| 153 | }, | |
| 154 | }); | |
| 155 | commands.import.mockReset().mockResolvedValue({ | |
| 156 | _tag: "Success", | |
| 157 | value: { threadId: ThreadId.make("thread-imported"), imported: true }, | |
| 158 | }); | |
| 159 | commands.delete.mockReset().mockResolvedValue({ | |
| 160 | _tag: "Success", | |
| 161 | value: { deleted: true }, | |
| 162 | }); | |
| 163 | commands.listProviders.mockReset().mockResolvedValue({ | |
| 164 | _tag: "Success", | |
| 165 | value: { | |
| 166 | providers: [ | |
| 167 | { | |
| 168 | providerId: "google", | |
| 169 | supported: ["openai"], | |
| 170 | required: false, | |
| 171 | current: { apiType: "openai", baseUrl: "https://api.example.test/v1" }, | |
| 172 | }, | |
| 173 | ], | |
| 174 | }, | |
| 175 | }); | |
| 176 | commands.setProvider.mockReset().mockResolvedValue({ | |
| 177 | _tag: "Success", | |
| 178 | value: { configured: true }, | |
| 179 | }); | |
| 180 | commands.disableProvider.mockReset().mockResolvedValue({ | |
| 181 | _tag: "Success", | |
| 182 | value: { disabled: true }, | |
| 183 | }); | |
| 184 | commands.logout.mockReset().mockResolvedValue({ | |
| 185 | _tag: "Success", | |
| 186 | value: { loggedOut: true }, | |
| 187 | }); | |
| 188 | dialogs.confirm.mockReset().mockResolvedValue(true); | |
| 148 | beforeEach(() => { | |
| 149 | hooks.reset(); | |
| 150 | grants.scopes = new Set(["orchestration:read", "orchestration:operate", "providers:manage"]); | |
| 151 | commands.list.mockReset().mockResolvedValue({ | |
| 152 | _tag: "Success", | |
| 153 | value: { | |
| 154 | sessions: [session], | |
| 155 | nextCursor: null, | |
| 156 | canLoad: true, | |
| 157 | canResume: true, | |
| 158 | canDelete: true, | |
| 159 | }, | |
| 160 | }); | |
| 161 | commands.import.mockReset().mockResolvedValue({ | |
| 162 | _tag: "Success", | |
| 163 | value: { threadId: ThreadId.make("thread-imported"), imported: true }, | |
| 164 | }); | |
| 165 | commands.delete.mockReset().mockResolvedValue({ | |
| 166 | _tag: "Success", | |
| 167 | value: { deleted: true }, | |
| 189 | 168 | }); |
| 169 | commands.listProviders.mockReset().mockResolvedValue({ | |
| 170 | _tag: "Success", | |
| 171 | value: { | |
| 172 | providers: [ | |
| 173 | { | |
| 174 | providerId: "google", | |
| 175 | supported: ["openai"], | |
| 176 | required: false, | |
| 177 | current: { apiType: "openai", baseUrl: "https://api.example.test/v1" }, | |
| 178 | }, | |
| 179 | ], | |
| 180 | }, | |
| 181 | }); | |
| 182 | commands.setProvider.mockReset().mockResolvedValue({ | |
| 183 | _tag: "Success", | |
| 184 | value: { configured: true }, | |
| 185 | }); | |
| 186 | commands.disableProvider.mockReset().mockResolvedValue({ | |
| 187 | _tag: "Success", | |
| 188 | value: { disabled: true }, | |
| 189 | }); | |
| 190 | commands.logout.mockReset().mockResolvedValue({ | |
| 191 | _tag: "Success", | |
| 192 | value: { loggedOut: true }, | |
| 193 | }); | |
| 194 | dialogs.confirm.mockReset().mockResolvedValue(true); | |
| 195 | }); | |
| 190 | 196 | |
| 197 | describe("AcpSessionManagementSection", () => { | |
| 191 | 198 | it("lists and imports native sessions through the owning environment", async () => { |
| 192 | 199 | const initial = render(); |
| 193 | 200 | (findByLabel(initial, "List sessions").props.onClick as (() => void) | undefined)?.(); |
| ... | ... | @@ -292,3 +299,33 @@ describe("AcpSessionManagementSection", () => { |
| 292 | 299 | }); |
| 293 | 300 | }); |
| 294 | 301 | }); |
| 302 | ||
| 303 | it("imports with task access even when provider configuration is read-only", async () => { | |
| 304 | grants.scopes.delete("providers:manage"); | |
| 305 | (findByLabel(render(true), "List sessions").props.onClick as () => void)(); | |
| 306 | await flushPromises(); | |
| 307 | (findByLabel(render(true), "Import").props.onClick as () => void)(); | |
| 308 | await flushPromises(); | |
| 309 | expect(commands.import).toHaveBeenCalledOnce(); | |
| 310 | }); | |
| 311 | ||
| 312 | it("does not import with provider management alone", async () => { | |
| 313 | grants.scopes.delete("orchestration:operate"); | |
| 314 | (findByLabel(render(), "List sessions").props.onClick as () => void)(); | |
| 315 | await flushPromises(); | |
| 316 | (findByLabel(render(), "Import").props.onClick as () => void)(); | |
| 317 | await flushPromises(); | |
| 318 | expect(commands.import).not.toHaveBeenCalled(); | |
| 319 | }); | |
| 320 | ||
| 321 | it("rechecks task access after confirming deletion", async () => { | |
| 322 | (findByLabel(render(), "List sessions").props.onClick as () => void)(); | |
| 323 | await flushPromises(); | |
| 324 | dialogs.confirm.mockImplementationOnce(async () => { | |
| 325 | grants.scopes.delete("orchestration:operate"); | |
| 326 | return true; | |
| 327 | }); | |
| 328 | (findByLabel(render(), "Delete").props.onClick as () => void)(); | |
| 329 | await flushPromises(); | |
| 330 | expect(commands.delete).not.toHaveBeenCalled(); | |
| 331 | }); |
apps/web/src/components/settings/AcpSessionManagementSection.tsx+31-12| ... | ... | @@ -1,3 +1,9 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthProvidersManageScope, | |
| 4 | AuthOrchestrationReadScope, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { useEnvironmentScope, readEnvironmentScope } from "~/state/session"; | |
| 1 | 7 | import type { |
| 2 | 8 | AcpRegistryConfigurableProvider, |
| 3 | 9 | AcpRegistrySession, |
| ... | ... | @@ -45,6 +51,8 @@ export function AcpSessionManagementSection(props: { |
| 45 | 51 | readonly projects: ReadonlyArray<AcpSessionProject>; |
| 46 | 52 | readonly readOnly: boolean; |
| 47 | 53 | }) { |
| 54 | const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); | |
| 55 | const canRead = useEnvironmentScope(props.environmentId, AuthOrchestrationReadScope); | |
| 48 | 56 | const [projectId, setProjectId] = useState<ProjectId | null>(props.projects[0]?.id ?? null); |
| 49 | 57 | const [sessions, setSessions] = useState<ReadonlyArray<AcpRegistrySession>>([]); |
| 50 | 58 | const [nextCursor, setNextCursor] = useState<string | null>(null); |
| ... | ... | @@ -117,7 +125,12 @@ export function AcpSessionManagementSection(props: { |
| 117 | 125 | }; |
| 118 | 126 | |
| 119 | 127 | const importNativeSession = async (session: AcpRegistrySession) => { |
| 120 | if (projectId === null || importingSessionId !== null) return; | |
| 128 | if ( | |
| 129 | !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) || | |
| 130 | projectId === null || | |
| 131 | importingSessionId !== null | |
| 132 | ) | |
| 133 | return; | |
| 121 | 134 | setImportingSessionId(session.sessionId); |
| 122 | 135 | const result = await importSession({ |
| 123 | 136 | environmentId: props.environmentId, |
| ... | ... | @@ -154,7 +167,8 @@ export function AcpSessionManagementSection(props: { |
| 154 | 167 | `Permanently delete native ACP session "${session.title ?? session.sessionId}"?`, |
| 155 | 168 | { variant: "destructive" }, |
| 156 | 169 | ); |
| 157 | if (!confirmed) return; | |
| 170 | if (!confirmed || !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope)) | |
| 171 | return; | |
| 158 | 172 | setDeletingSessionId(session.sessionId); |
| 159 | 173 | const result = await deleteSession({ |
| 160 | 174 | environmentId: props.environmentId, |
| ... | ... | @@ -199,7 +213,12 @@ export function AcpSessionManagementSection(props: { |
| 199 | 213 | }; |
| 200 | 214 | |
| 201 | 215 | const saveProvider = async (provider: AcpRegistryConfigurableProvider) => { |
| 202 | if (projectId === null || savingProviderId !== null) return; | |
| 216 | if ( | |
| 217 | !readEnvironmentScope(props.environmentId, AuthProvidersManageScope) || | |
| 218 | projectId === null || | |
| 219 | savingProviderId !== null | |
| 220 | ) | |
| 221 | return; | |
| 203 | 222 | const draft = providerDrafts[provider.providerId]; |
| 204 | 223 | if (draft === undefined || draft.apiType.length === 0 || draft.baseUrl.length === 0) return; |
| 205 | 224 | let headers: Record<string, string> | undefined; |
| ... | ... | @@ -251,7 +270,7 @@ export function AcpSessionManagementSection(props: { |
| 251 | 270 | `Disable ACP provider "${provider.providerId}"?`, |
| 252 | 271 | { variant: "destructive" }, |
| 253 | 272 | ); |
| 254 | if (!confirmed) return; | |
| 273 | if (!confirmed || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return; | |
| 255 | 274 | setSavingProviderId(provider.providerId); |
| 256 | 275 | const result = await disableProvider({ |
| 257 | 276 | environmentId: props.environmentId, |
| ... | ... | @@ -267,7 +286,7 @@ export function AcpSessionManagementSection(props: { |
| 267 | 286 | }; |
| 268 | 287 | |
| 269 | 288 | const logoutProvider = async () => { |
| 270 | if (loggingOut) return; | |
| 289 | if (loggingOut || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return; | |
| 271 | 290 | setLoggingOut(true); |
| 272 | 291 | const result = await logout({ |
| 273 | 292 | environmentId: props.environmentId, |
| ... | ... | @@ -310,7 +329,7 @@ export function AcpSessionManagementSection(props: { |
| 310 | 329 | <> |
| 311 | 330 | <Select |
| 312 | 331 | value={projectId ?? ""} |
| 313 | disabled={props.readOnly || projectOperationPending} | |
| 332 | disabled={!canRead || projectOperationPending} | |
| 314 | 333 | onValueChange={(value) => { |
| 315 | 334 | if (value === null) return; |
| 316 | 335 | setProjectId(value as ProjectId); |
| ... | ... | @@ -337,7 +356,7 @@ export function AcpSessionManagementSection(props: { |
| 337 | 356 | type="button" |
| 338 | 357 | size="sm" |
| 339 | 358 | variant="outline" |
| 340 | disabled={props.readOnly || loading || projectId === null} | |
| 359 | disabled={!canRead || loading || projectId === null} | |
| 341 | 360 | onClick={() => void loadSessions()} |
| 342 | 361 | > |
| 343 | 362 | {loading ? "Loading" : sessions.length === 0 ? "List sessions" : "Refresh"} |
| ... | ... | @@ -370,7 +389,7 @@ export function AcpSessionManagementSection(props: { |
| 370 | 389 | size="xs" |
| 371 | 390 | variant="ghost-muted" |
| 372 | 391 | disabled={ |
| 373 | props.readOnly || | |
| 392 | !canOperate || | |
| 374 | 393 | !canImport || |
| 375 | 394 | session.importedThreadId !== null || |
| 376 | 395 | importingSessionId !== null |
| ... | ... | @@ -389,7 +408,7 @@ export function AcpSessionManagementSection(props: { |
| 389 | 408 | size="xs" |
| 390 | 409 | variant="ghost-muted" |
| 391 | 410 | disabled={ |
| 392 | props.readOnly || | |
| 411 | !canOperate || | |
| 393 | 412 | session.importedThreadId !== null || |
| 394 | 413 | deletingSessionId !== null |
| 395 | 414 | } |
| ... | ... | @@ -410,7 +429,7 @@ export function AcpSessionManagementSection(props: { |
| 410 | 429 | size="xs" |
| 411 | 430 | variant="ghost-muted" |
| 412 | 431 | className="w-fit" |
| 413 | disabled={props.readOnly || loading} | |
| 432 | disabled={!canRead || loading} | |
| 414 | 433 | onClick={() => void loadSessions(nextCursor)} |
| 415 | 434 | > |
| 416 | 435 | {loading ? "Loading" : "Load more"} |
| ... | ... | @@ -433,7 +452,7 @@ export function AcpSessionManagementSection(props: { |
| 433 | 452 | type="button" |
| 434 | 453 | size="xs" |
| 435 | 454 | variant="outline" |
| 436 | disabled={props.readOnly || loadingProviders || projectId === null} | |
| 455 | disabled={!canRead || loadingProviders || projectId === null} | |
| 437 | 456 | onClick={() => void loadProviders()} |
| 438 | 457 | > |
| 439 | 458 | {loadingProviders ? "Loading" : providers.length === 0 ? "List providers" : "Refresh"} |
| ... | ... | @@ -443,7 +462,7 @@ export function AcpSessionManagementSection(props: { |
| 443 | 462 | {!canList && props.projects.length > 1 ? ( |
| 444 | 463 | <Select |
| 445 | 464 | value={projectId ?? ""} |
| 446 | disabled={props.readOnly || projectOperationPending} | |
| 465 | disabled={!canRead || projectOperationPending} | |
| 447 | 466 | onValueChange={(value) => { |
| 448 | 467 | if (value === null) return; |
| 449 | 468 | setProjectId(value as ProjectId); |
apps/web/src/components/settings/AddCodexAccountDialog.tsx+10-2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthProvidersManageScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 4 | import { |
| 3 | 5 | ProviderDriverKind, |
| ... | ... | @@ -31,6 +33,7 @@ export function AddCodexAccountDialog({ |
| 31 | 33 | | undefined; |
| 32 | 34 | readonly renderSetup: (instanceId: ProviderInstanceId, provider: ServerProvider) => ReactNode; |
| 33 | 35 | }) { |
| 36 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 34 | 37 | const settings = useEnvironmentSettings(environmentId); |
| 35 | 38 | const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); |
| 36 | 39 | const update = useAtomCommand(serverEnvironment.updateSettings, "Add ChatGPT account"); |
| ... | ... | @@ -47,7 +50,8 @@ export function AddCodexAccountDialog({ |
| 47 | 50 | }, [connected]); |
| 48 | 51 | |
| 49 | 52 | const createAccount = async () => { |
| 50 | if (pending || !name.trim()) return; | |
| 53 | if (pending || !name.trim() || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) | |
| 54 | return; | |
| 51 | 55 | setPending(true); |
| 52 | 56 | // The ID is routing identity; the name is editable and need not be unique. |
| 53 | 57 | const id = ProviderInstanceId.make(`codex_${randomUUID()}`); |
| ... | ... | @@ -131,7 +135,11 @@ export function AddCodexAccountDialog({ |
| 131 | 135 | <Button variant="outline" disabled={pending} onClick={onClose}> |
| 132 | 136 | Cancel |
| 133 | 137 | </Button> |
| 134 | <Button type="submit" form="add-codex-account" disabled={pending || !name.trim()}> | |
| 138 | <Button | |
| 139 | type="submit" | |
| 140 | form="add-codex-account" | |
| 141 | disabled={pending || !name.trim() || !canManageProviders} | |
| 142 | > | |
| 135 | 143 | {pending ? "Adding account…" : "Continue"} |
| 136 | 144 | </Button> |
| 137 | 145 | </> |
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx+101| ... | ... | @@ -4,6 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | import { visitElements } from "../../test/reactElementTree"; |
| 5 | 5 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; |
| 6 | 6 | |
| 7 | const actions = vi.hoisted(() => ({ | |
| 8 | update: vi.fn(), | |
| 9 | toast: vi.fn(), | |
| 10 | onOpenChange: vi.fn(), | |
| 11 | canManageProviders: true, | |
| 12 | })); | |
| 13 | ||
| 7 | 14 | const settingsHooks = vi.hoisted(() => ({ |
| 8 | 15 | read: vi.fn(() => ({ providerInstances: {} })), |
| 9 | 16 | mutate: vi.fn(), |
| ... | ... | @@ -35,6 +42,15 @@ vi.mock("../../hooks/useSettings", () => ({ |
| 35 | 42 | usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation, |
| 36 | 43 | })); |
| 37 | 44 | |
| 45 | vi.mock("../../state/session", async (importOriginal) => { | |
| 46 | const actual = await importOriginal<typeof import("../../state/session")>(); | |
| 47 | const hasScope = (environmentId: EnvironmentId, scope: string) => | |
| 48 | environmentId === "remote-device" && scope === "providers:manage" && actions.canManageProviders; | |
| 49 | return { ...actual, useEnvironmentScope: hasScope, readEnvironmentScope: hasScope }; | |
| 50 | }); | |
| 51 | ||
| 52 | vi.mock("../ui/toast", () => ({ toastManager: { add: actions.toast } })); | |
| 53 | ||
| 38 | 54 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 39 | 55 | |
| 40 | 56 | const remoteEnvironmentId = EnvironmentId.make("remote-device"); |
| ... | ... | @@ -81,9 +97,45 @@ async function selectPreparedAcp() { |
| 81 | 97 | ); |
| 82 | 98 | } |
| 83 | 99 | |
| 100 | function renderDialog() { | |
| 101 | hooks.beginRender(); | |
| 102 | return AddProviderInstanceDialog({ | |
| 103 | open: true, | |
| 104 | environmentId: remoteEnvironmentId, | |
| 105 | environmentLabel: "Remote device", | |
| 106 | onOpenChange: actions.onOpenChange, | |
| 107 | }); | |
| 108 | } | |
| 109 | ||
| 110 | function button(dialog: unknown, label: string) { | |
| 111 | const element = visitElements( | |
| 112 | dialog, | |
| 113 | (entry) => entry.props.children === label && typeof entry.props.onClick === "function", | |
| 114 | ); | |
| 115 | if (!element) throw new Error(`Missing button: ${label}`); | |
| 116 | return element; | |
| 117 | } | |
| 118 | ||
| 119 | function prepareInstance() { | |
| 120 | let dialog = renderDialog(); | |
| 121 | (button(dialog, "Configure manually").props.onClick as () => void)(); | |
| 122 | dialog = renderDialog(); | |
| 123 | const label = visitElements(dialog, (entry) => entry.props.placeholder === "e.g. Work"); | |
| 124 | if (!label) throw new Error("Missing instance label input."); | |
| 125 | (label.props.onChange as (event: { target: { value: string } }) => void)({ | |
| 126 | target: { value: "Work" }, | |
| 127 | }); | |
| 128 | dialog = renderDialog(); | |
| 129 | (button(dialog, "Next").props.onClick as () => void)(); | |
| 130 | return renderDialog(); | |
| 131 | } | |
| 132 | ||
| 84 | 133 | describe("AddProviderInstanceDialog environment routing", () => { |
| 85 | 134 | beforeEach(() => { |
| 86 | 135 | hooks.reset(); |
| 136 | actions.canManageProviders = true; | |
| 137 | actions.toast.mockReset(); | |
| 138 | actions.onOpenChange.mockReset(); | |
| 87 | 139 | settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} }); |
| 88 | 140 | settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); |
| 89 | 141 | settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate); |
| ... | ... | @@ -321,4 +373,53 @@ describe("AddProviderInstanceDialog environment routing", () => { |
| 321 | 373 | |
| 322 | 374 | expect(onOpenChange).not.toHaveBeenCalled(); |
| 323 | 375 | }); |
| 376 | ||
| 377 | it("adds an instance with the selected environment's provider grant alone", async () => { | |
| 378 | const dialog = prepareInstance(); | |
| 379 | (button(dialog, "Add instance").props.onClick as () => void)(); | |
| 380 | ||
| 381 | await Promise.resolve(); | |
| 382 | expect(settingsHooks.mutate).toHaveBeenCalledWith({ | |
| 383 | operation: "create", | |
| 384 | instanceId: "codex_work", | |
| 385 | instance: { | |
| 386 | driver: "codex", | |
| 387 | enabled: true, | |
| 388 | displayName: "Work", | |
| 389 | config: { setupMode: "existing" }, | |
| 390 | }, | |
| 391 | }); | |
| 392 | expect(actions.toast).toHaveBeenCalledWith( | |
| 393 | expect.objectContaining({ type: "success", title: "Provider instance added" }), | |
| 394 | ); | |
| 395 | expect(actions.onOpenChange).toHaveBeenCalledWith(false); | |
| 396 | }); | |
| 397 | ||
| 398 | it("rejects a queued save after the provider grant is revoked", () => { | |
| 399 | const dialog = prepareInstance(); | |
| 400 | const save = button(dialog, "Add instance").props.onClick as () => void; | |
| 401 | actions.canManageProviders = false; | |
| 402 | save(); | |
| 403 | ||
| 404 | expect(settingsHooks.mutate).not.toHaveBeenCalled(); | |
| 405 | expect(actions.toast).not.toHaveBeenCalled(); | |
| 406 | expect(actions.onOpenChange).not.toHaveBeenCalled(); | |
| 407 | expect(button(renderDialog(), "Add instance").props.disabled).toBe(true); | |
| 408 | }); | |
| 409 | ||
| 410 | it("keeps a denied draft available when the provider grant arrives", async () => { | |
| 411 | actions.canManageProviders = false; | |
| 412 | let dialog = prepareInstance(); | |
| 413 | (button(dialog, "Add instance").props.onClick as () => void)(); | |
| 414 | expect(settingsHooks.mutate).not.toHaveBeenCalled(); | |
| 415 | expect(actions.toast).not.toHaveBeenCalled(); | |
| 416 | ||
| 417 | actions.canManageProviders = true; | |
| 418 | dialog = renderDialog(); | |
| 419 | expect(button(dialog, "Add instance").props.disabled).toBe(false); | |
| 420 | (button(dialog, "Add instance").props.onClick as () => void)(); | |
| 421 | await Promise.resolve(); | |
| 422 | expect(settingsHooks.mutate).toHaveBeenCalledOnce(); | |
| 423 | expect(actions.onOpenChange).toHaveBeenCalledWith(false); | |
| 424 | }); | |
| 324 | 425 | }); |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+9-1| ... | ... | @@ -9,6 +9,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" |
| 9 | 9 | import { |
| 10 | 10 | DEFAULT_UNIFIED_SETTINGS, |
| 11 | 11 | type AcpRegistrySearchAgent, |
| 12 | AuthProvidersManageScope, | |
| 12 | 13 | ProviderInstanceId, |
| 13 | 14 | ProviderDriverKind, |
| 14 | 15 | type EnvironmentId, |
| ... | ... | @@ -24,6 +25,7 @@ import * as Equal from "effect/Equal"; |
| 24 | 25 | |
| 25 | 26 | import { cn } from "../../lib/utils"; |
| 26 | 27 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 28 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 27 | 29 | import { Button } from "../ui/button"; |
| 28 | 30 | import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; |
| 29 | 31 | import { Dialog } from "../ui/dialog"; |
| ... | ... | @@ -113,6 +115,7 @@ export function AddProviderInstanceDialog({ |
| 113 | 115 | }: AddProviderInstanceDialogProps) { |
| 114 | 116 | const settings = useEnvironmentSettings(environmentId); |
| 115 | 117 | const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); |
| 118 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 116 | 119 | |
| 117 | 120 | const [wizardStep, setWizardStep] = useState(0); |
| 118 | 121 | const [addingChatGptAccount, setAddingChatGptAccount] = useState(false); |
| ... | ... | @@ -308,6 +311,7 @@ export function AddProviderInstanceDialog({ |
| 308 | 311 | }; |
| 309 | 312 | |
| 310 | 313 | const handleSave = async () => { |
| 314 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 311 | 315 | if (isSaving || createdInstanceId) return; |
| 312 | 316 | setHasAttemptedSubmit(true); |
| 313 | 317 | if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return; |
| ... | ... | @@ -694,7 +698,11 @@ export function AddProviderInstanceDialog({ |
| 694 | 698 | Next |
| 695 | 699 | </Button> |
| 696 | 700 | ) : ( |
| 697 | <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}> | |
| 701 | <Button | |
| 702 | size="sm" | |
| 703 | disabled={isSaving || !canManageProviders} | |
| 704 | onClick={() => void handleSave()} | |
| 705 | > | |
| 698 | 706 | {isSaving |
| 699 | 707 | ? "Adding..." |
| 700 | 708 | : isAcpRegistry && !isLocalAcp |
apps/web/src/components/settings/CodexSetupSection.tsx+4| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthProvidersManageScope } from "@t3tools/contracts"; | |
| 2 | import { useEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { |
| 2 | 4 | isAtomCommandInterrupted, |
| 3 | 5 | squashAtomCommandFailure, |
| ... | ... | @@ -167,6 +169,7 @@ export function AddManagedCodexAccountDialog({ |
| 167 | 169 | | ((instanceId: ProviderInstanceId, displayName: string) => void) |
| 168 | 170 | | undefined; |
| 169 | 171 | }) { |
| 172 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 170 | 173 | return ( |
| 171 | 174 | <AddCodexAccountDialog |
| 172 | 175 | environmentId={environmentId} |
| ... | ... | @@ -180,6 +183,7 @@ export function AddManagedCodexAccountDialog({ |
| 180 | 183 | provider={provider} |
| 181 | 184 | mode="managed" |
| 182 | 185 | enabled |
| 186 | readOnly={!canManageProviders} | |
| 183 | 187 | autoStart |
| 184 | 188 | onAutoStartConsumed={noop} |
| 185 | 189 | onModeChange={noop} |
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+13| ... | ... | @@ -2,6 +2,7 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | applyWslEnableSelection, |
| 5 | canRevokeOtherClients, | |
| 5 | 6 | isQrShareableEndpoint, |
| 6 | 7 | isWslSettingsRowVisible, |
| 7 | 8 | selectQrEndpointOption, |
| ... | ... | @@ -183,3 +184,15 @@ describe("selectQrEndpointOption", () => { |
| 183 | 184 | expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); |
| 184 | 185 | }); |
| 185 | 186 | }); |
| 187 | ||
| 188 | describe("canRevokeOtherClients", () => { | |
| 189 | it("permits revocation when a write-only grant cannot list clients", () => { | |
| 190 | expect(canRevokeOtherClients(null)).toBe(true); | |
| 191 | }); | |
| 192 | ||
| 193 | it("disables revocation only when a loaded list has no other clients", () => { | |
| 194 | expect(canRevokeOtherClients([])).toBe(false); | |
| 195 | expect(canRevokeOtherClients([{ current: true }])).toBe(false); | |
| 196 | expect(canRevokeOtherClients([{ current: true }, { current: false }])).toBe(true); | |
| 197 | }); | |
| 198 | }); |
apps/web/src/components/settings/ConnectionsSettings.logic.ts+7| ... | ... | @@ -1,5 +1,12 @@ |
| 1 | 1 | import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | /** A missing access list says nothing about whether other clients exist. */ | |
| 4 | export function canRevokeOtherClients( | |
| 5 | clientSessions: ReadonlyArray<{ readonly current: boolean }> | null, | |
| 6 | ): boolean { | |
| 7 | return clientSessions === null || clientSessions.some((session) => !session.current); | |
| 8 | } | |
| 9 | ||
| 3 | 10 | type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">; |
| 4 | 11 | |
| 5 | 12 | /** |
apps/web/src/components/settings/ConnectionsSettings.tsx+281-121| ... | ... | @@ -23,7 +23,9 @@ import { |
| 23 | 23 | import { |
| 24 | 24 | AuthAccessReadScope, |
| 25 | 25 | AuthAccessWriteScope, |
| 26 | AuthAdministrativeScopes, | |
| 26 | AuthSettingsWriteScope, | |
| 27 | AuthProvidersManageScope, | |
| 28 | AuthEnvironmentMaintainScope, | |
| 27 | 29 | AuthOrchestrationOperateScope, |
| 28 | 30 | AuthOrchestrationReadScope, |
| 29 | 31 | AuthRelayReadScope, |
| ... | ... | @@ -64,6 +66,7 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam |
| 64 | 66 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 65 | 67 | import { |
| 66 | 68 | applyWslEnableSelection, |
| 69 | canRevokeOtherClients, | |
| 67 | 70 | isQrShareableEndpoint, |
| 68 | 71 | isWslSettingsRowVisible, |
| 69 | 72 | selectQrEndpointOption, |
| ... | ... | @@ -162,6 +165,7 @@ import { |
| 162 | 165 | connectPairing as connectPairingAtom, |
| 163 | 166 | connectSshEnvironment as connectSshEnvironmentAtom, |
| 164 | 167 | } from "~/connection/onboarding"; |
| 168 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 165 | 169 | import { useEnvironmentQuery } from "~/state/query"; |
| 166 | 170 | import { |
| 167 | 171 | desktopNetworkAccessStateAtom, |
| ... | ... | @@ -174,6 +178,7 @@ import { |
| 174 | 178 | useEnvironments, |
| 175 | 179 | usePrimaryEnvironment, |
| 176 | 180 | useRelayEnvironmentDiscovery, |
| 181 | usePrimaryEnvironmentId, | |
| 177 | 182 | } from "~/state/environments"; |
| 178 | 183 | import { APP_VERSION } from "~/branding"; |
| 179 | 184 | import { requestConfirmDialog } from "~/confirmDialog"; |
| ... | ... | @@ -234,6 +239,21 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 234 | 239 | title: "Operate tasks", |
| 235 | 240 | description: "Start tasks and perform changes in the environment.", |
| 236 | 241 | }, |
| 242 | { | |
| 243 | scope: AuthSettingsWriteScope, | |
| 244 | title: "Change environment settings", | |
| 245 | description: "Edit environment preferences and keybindings.", | |
| 246 | }, | |
| 247 | { | |
| 248 | scope: AuthProvidersManageScope, | |
| 249 | title: "Manage providers", | |
| 250 | description: "Configure, install, sign in to, and update providers and usage sources.", | |
| 251 | }, | |
| 252 | { | |
| 253 | scope: AuthEnvironmentMaintainScope, | |
| 254 | title: "Maintain environment", | |
| 255 | description: "Update the server and control environment processes.", | |
| 256 | }, | |
| 237 | 257 | { |
| 238 | 258 | scope: AuthTerminalOperateScope, |
| 239 | 259 | title: "Use terminals", |
| ... | ... | @@ -600,6 +620,7 @@ type PairingLinkListRowProps = { |
| 600 | 620 | presentation?: AccessSectionPresentation; |
| 601 | 621 | revokingPairingLinkId: string | null; |
| 602 | 622 | onRevoke: (id: string) => void; |
| 623 | canRevoke: boolean; | |
| 603 | 624 | }; |
| 604 | 625 | |
| 605 | 626 | const PairingLinkListRow = memo(function PairingLinkListRow({ |
| ... | ... | @@ -611,6 +632,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 611 | 632 | presentation = "current", |
| 612 | 633 | revokingPairingLinkId, |
| 613 | 634 | onRevoke, |
| 635 | canRevoke, | |
| 614 | 636 | }: PairingLinkListRowProps) { |
| 615 | 637 | const nowMs = useRelativeTimeTick(1_000); |
| 616 | 638 | const expiresAtMs = useMemo( |
| ... | ... | @@ -877,7 +899,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 877 | 899 | <Button |
| 878 | 900 | size="xs" |
| 879 | 901 | variant="destructive-outline" |
| 880 | disabled={revokingPairingLinkId === pairingLink.id} | |
| 902 | disabled={!canRevoke || revokingPairingLinkId === pairingLink.id} | |
| 881 | 903 | onClick={() => void onRevoke(pairingLink.id)} |
| 882 | 904 | > |
| 883 | 905 | {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"} |
| ... | ... | @@ -982,6 +1004,7 @@ type ConnectedClientListRowProps = { |
| 982 | 1004 | presentation?: AccessSectionPresentation; |
| 983 | 1005 | revokingClientSessionId: string | null; |
| 984 | 1006 | onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; |
| 1007 | canRevoke: boolean; | |
| 985 | 1008 | }; |
| 986 | 1009 | |
| 987 | 1010 | const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| ... | ... | @@ -989,6 +1012,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 989 | 1012 | presentation = "current", |
| 990 | 1013 | revokingClientSessionId, |
| 991 | 1014 | onRevokeSession, |
| 1015 | canRevoke, | |
| 992 | 1016 | }: ConnectedClientListRowProps) { |
| 993 | 1017 | const nowMs = useRelativeTimeTick(1_000); |
| 994 | 1018 | const isLive = clientSession.current || clientSession.connected; |
| ... | ... | @@ -1045,7 +1069,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 1045 | 1069 | <Button |
| 1046 | 1070 | size="xs" |
| 1047 | 1071 | variant="destructive-outline" |
| 1048 | disabled={revokingClientSessionId === clientSession.sessionId} | |
| 1072 | disabled={!canRevoke || revokingClientSessionId === clientSession.sessionId} | |
| 1049 | 1073 | onClick={() => void onRevokeSession(clientSession.sessionId)} |
| 1050 | 1074 | > |
| 1051 | 1075 | {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"} |
| ... | ... | @@ -1059,9 +1083,10 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 1059 | 1083 | |
| 1060 | 1084 | type AuthorizedClientsHeaderActionProps = { |
| 1061 | 1085 | onPairingLinkCreated: (result: AuthPairingCredentialResult) => void; |
| 1062 | clientSessions: ReadonlyArray<ServerClientSessionRecord>; | |
| 1086 | clientSessions: ReadonlyArray<ServerClientSessionRecord> | null; | |
| 1063 | 1087 | isRevokingOtherClients: boolean; |
| 1064 | 1088 | onRevokeOtherClients: () => void; |
| 1089 | delegatableScopes: ReadonlyArray<AuthEnvironmentScope>; | |
| 1065 | 1090 | }; |
| 1066 | 1091 | |
| 1067 | 1092 | const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({ |
| ... | ... | @@ -1069,24 +1094,36 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1069 | 1094 | clientSessions, |
| 1070 | 1095 | isRevokingOtherClients, |
| 1071 | 1096 | onRevokeOtherClients, |
| 1097 | delegatableScopes, | |
| 1072 | 1098 | }: AuthorizedClientsHeaderActionProps) { |
| 1099 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 1073 | 1100 | const [dialogOpen, setDialogOpen] = useState(false); |
| 1074 | 1101 | const [pairingLabel, setPairingLabel] = useState(""); |
| 1075 | 1102 | const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([ |
| 1076 | 1103 | ...AuthStandardClientScopes, |
| 1077 | 1104 | ]); |
| 1105 | const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope)); | |
| 1078 | 1106 | const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); |
| 1079 | 1107 | |
| 1080 | 1108 | const handleCreatePairingLink = useCallback(async () => { |
| 1109 | if ( | |
| 1110 | primaryEnvironmentId === null || | |
| 1111 | selectedScopes.length === 0 || | |
| 1112 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) || | |
| 1113 | !selectedScopes.every((scope) => readEnvironmentScope(primaryEnvironmentId, scope)) | |
| 1114 | ) | |
| 1115 | return; | |
| 1081 | 1116 | setIsCreatingPairingLink(true); |
| 1082 | 1117 | try { |
| 1083 | 1118 | const created = await createServerPairingCredential({ |
| 1084 | 1119 | label: pairingLabel, |
| 1085 | scopes: pairingScopes, | |
| 1120 | scopes: selectedScopes, | |
| 1086 | 1121 | }); |
| 1087 | 1122 | onPairingLinkCreated(created); |
| 1088 | 1123 | setPairingLabel(""); |
| 1089 | setPairingScopes([...AuthStandardClientScopes]); | |
| 1124 | setPairingScopes( | |
| 1125 | AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)), | |
| 1126 | ); | |
| 1090 | 1127 | setDialogOpen(false); |
| 1091 | 1128 | } catch (error) { |
| 1092 | 1129 | const message = error instanceof Error ? error.message : "Failed to create pairing URL."; |
| ... | ... | @@ -1100,7 +1137,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1100 | 1137 | } finally { |
| 1101 | 1138 | setIsCreatingPairingLink(false); |
| 1102 | 1139 | } |
| 1103 | }, [onPairingLinkCreated, pairingLabel, pairingScopes]); | |
| 1140 | }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]); | |
| 1104 | 1141 | |
| 1105 | 1142 | const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { |
| 1106 | 1143 | setPairingScopes((current) => |
| ... | ... | @@ -1113,9 +1150,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1113 | 1150 | <Button |
| 1114 | 1151 | size="xs" |
| 1115 | 1152 | variant="destructive-outline" |
| 1116 | disabled={ | |
| 1117 | isRevokingOtherClients || clientSessions.every((clientSession) => clientSession.current) | |
| 1118 | } | |
| 1153 | disabled={isRevokingOtherClients || !canRevokeOtherClients(clientSessions)} | |
| 1119 | 1154 | onClick={() => void onRevokeOtherClients()} |
| 1120 | 1155 | > |
| 1121 | 1156 | {isRevokingOtherClients ? "Revoking…" : "Revoke others"} |
| ... | ... | @@ -1126,7 +1161,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1126 | 1161 | setDialogOpen(open); |
| 1127 | 1162 | if (!open) { |
| 1128 | 1163 | setPairingLabel(""); |
| 1129 | setPairingScopes([...AuthStandardClientScopes]); | |
| 1164 | setPairingScopes( | |
| 1165 | AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)), | |
| 1166 | ); | |
| 1130 | 1167 | } |
| 1131 | 1168 | }} |
| 1132 | 1169 | > |
| ... | ... | @@ -1172,7 +1209,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1172 | 1209 | size="xs" |
| 1173 | 1210 | variant="outline" |
| 1174 | 1211 | disabled={isCreatingPairingLink} |
| 1175 | onClick={() => setPairingScopes([AuthOrchestrationReadScope])} | |
| 1212 | onClick={() => | |
| 1213 | setPairingScopes( | |
| 1214 | delegatableScopes.includes(AuthOrchestrationReadScope) | |
| 1215 | ? [AuthOrchestrationReadScope] | |
| 1216 | : [], | |
| 1217 | ) | |
| 1218 | } | |
| 1176 | 1219 | > |
| 1177 | 1220 | Read only |
| 1178 | 1221 | </Button> |
| ... | ... | @@ -1180,36 +1223,44 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1180 | 1223 | size="xs" |
| 1181 | 1224 | variant="outline" |
| 1182 | 1225 | disabled={isCreatingPairingLink} |
| 1183 | onClick={() => setPairingScopes([...AuthStandardClientScopes])} | |
| 1226 | onClick={() => | |
| 1227 | setPairingScopes( | |
| 1228 | AuthStandardClientScopes.filter((scope) => | |
| 1229 | delegatableScopes.includes(scope), | |
| 1230 | ), | |
| 1231 | ) | |
| 1232 | } | |
| 1184 | 1233 | > |
| 1185 | 1234 | Standard |
| 1186 | 1235 | </Button> |
| 1187 | 1236 | </div> |
| 1188 | 1237 | </div> |
| 1189 | 1238 | <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25"> |
| 1190 | {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => ( | |
| 1191 | <label | |
| 1192 | key={scope} | |
| 1193 | className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40" | |
| 1194 | > | |
| 1195 | <Checkbox | |
| 1196 | className="mt-0.5" | |
| 1197 | checked={pairingScopes.includes(scope)} | |
| 1198 | disabled={isCreatingPairingLink} | |
| 1199 | onCheckedChange={(checked) => togglePairingScope(scope, checked === true)} | |
| 1200 | /> | |
| 1201 | <span className="min-w-0"> | |
| 1202 | <span className="block text-xs font-medium text-foreground">{title}</span> | |
| 1203 | <span className="block text-xs leading-snug text-muted-foreground"> | |
| 1204 | {description} | |
| 1239 | {PAIRING_SCOPE_OPTIONS.filter(({ scope }) => delegatableScopes.includes(scope)).map( | |
| 1240 | ({ scope, title, description }) => ( | |
| 1241 | <label | |
| 1242 | key={scope} | |
| 1243 | className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40" | |
| 1244 | > | |
| 1245 | <Checkbox | |
| 1246 | className="mt-0.5" | |
| 1247 | checked={pairingScopes.includes(scope)} | |
| 1248 | disabled={isCreatingPairingLink} | |
| 1249 | onCheckedChange={(checked) => togglePairingScope(scope, checked === true)} | |
| 1250 | /> | |
| 1251 | <span className="min-w-0"> | |
| 1252 | <span className="block text-xs font-medium text-foreground">{title}</span> | |
| 1253 | <span className="block text-xs leading-snug text-muted-foreground"> | |
| 1254 | {description} | |
| 1255 | </span> | |
| 1205 | 1256 | </span> |
| 1206 | </span> | |
| 1207 | </label> | |
| 1208 | ))} | |
| 1257 | </label> | |
| 1258 | ), | |
| 1259 | )} | |
| 1209 | 1260 | </div> |
| 1210 | {pairingScopes.length === 0 ? ( | |
| 1261 | {selectedScopes.length === 0 ? ( | |
| 1211 | 1262 | <p className="text-xs text-destructive">Select at least one permission.</p> |
| 1212 | ) : pairingScopes.includes(AuthAccessWriteScope) ? ( | |
| 1263 | ) : selectedScopes.includes(AuthAccessWriteScope) ? ( | |
| 1213 | 1264 | <p className="text-xs text-warning"> |
| 1214 | 1265 | This client can create or revoke access for other devices. |
| 1215 | 1266 | </p> |
| ... | ... | @@ -1225,7 +1276,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 1225 | 1276 | Cancel |
| 1226 | 1277 | </Button> |
| 1227 | 1278 | <Button |
| 1228 | disabled={isCreatingPairingLink || pairingScopes.length === 0} | |
| 1279 | disabled={isCreatingPairingLink || selectedScopes.length === 0} | |
| 1229 | 1280 | onClick={() => void handleCreatePairingLink()} |
| 1230 | 1281 | > |
| 1231 | 1282 | {isCreatingPairingLink ? "Creating…" : "Create link"} |
| ... | ... | @@ -1250,6 +1301,7 @@ type PairingClientsListProps = { |
| 1250 | 1301 | revokingClientSessionId: string | null; |
| 1251 | 1302 | onRevokePairingLink: (id: string) => void; |
| 1252 | 1303 | onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; |
| 1304 | canRevoke: boolean; | |
| 1253 | 1305 | }; |
| 1254 | 1306 | |
| 1255 | 1307 | const PairingClientsList = memo(function PairingClientsList({ |
| ... | ... | @@ -1265,6 +1317,7 @@ const PairingClientsList = memo(function PairingClientsList({ |
| 1265 | 1317 | revokingClientSessionId, |
| 1266 | 1318 | onRevokePairingLink, |
| 1267 | 1319 | onRevokeClientSession, |
| 1320 | canRevoke, | |
| 1268 | 1321 | }: PairingClientsListProps) { |
| 1269 | 1322 | return ( |
| 1270 | 1323 | <> |
| ... | ... | @@ -1279,6 +1332,7 @@ const PairingClientsList = memo(function PairingClientsList({ |
| 1279 | 1332 | presentation={presentation} |
| 1280 | 1333 | revokingPairingLinkId={revokingPairingLinkId} |
| 1281 | 1334 | onRevoke={onRevokePairingLink} |
| 1335 | canRevoke={canRevoke} | |
| 1282 | 1336 | /> |
| 1283 | 1337 | ))} |
| 1284 | 1338 | |
| ... | ... | @@ -1289,6 +1343,7 @@ const PairingClientsList = memo(function PairingClientsList({ |
| 1289 | 1343 | presentation={presentation} |
| 1290 | 1344 | revokingClientSessionId={revokingClientSessionId} |
| 1291 | 1345 | onRevokeSession={onRevokeClientSession} |
| 1346 | canRevoke={canRevoke} | |
| 1292 | 1347 | /> |
| 1293 | 1348 | ))} |
| 1294 | 1349 | |
| ... | ... | @@ -1793,6 +1848,7 @@ function CloudLinkSwitch({ |
| 1793 | 1848 | } |
| 1794 | 1849 | |
| 1795 | 1850 | function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { |
| 1851 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 1796 | 1852 | const { |
| 1797 | 1853 | isSignedIn, |
| 1798 | 1854 | linkState: primaryCloudLinkState, |
| ... | ... | @@ -1813,6 +1869,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1813 | 1869 | const isBusy = isUpdating || isUpdatingPreference; |
| 1814 | 1870 | |
| 1815 | 1871 | const updateManagedTunnel = async (enabled: boolean) => { |
| 1872 | if ( | |
| 1873 | primaryEnvironmentId === null || | |
| 1874 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) | |
| 1875 | ) | |
| 1876 | return; | |
| 1816 | 1877 | setIsUpdating(true); |
| 1817 | 1878 | const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); |
| 1818 | 1879 | if (ok) { |
| ... | ... | @@ -1836,6 +1897,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1836 | 1897 | }; |
| 1837 | 1898 | |
| 1838 | 1899 | const updatePublishAgentActivity = async (enabled: boolean) => { |
| 1900 | if ( | |
| 1901 | primaryEnvironmentId === null || | |
| 1902 | !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) | |
| 1903 | ) | |
| 1904 | return; | |
| 1839 | 1905 | setIsUpdatingPreference(true); |
| 1840 | 1906 | const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled }); |
| 1841 | 1907 | if (ok) { |
| ... | ... | @@ -1883,7 +1949,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1883 | 1949 | control={ |
| 1884 | 1950 | <CloudLinkSwitch |
| 1885 | 1951 | checked={managedTunnelActive} |
| 1886 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy} | |
| 1952 | disabled={ | |
| 1953 | !canManageRelay || | |
| 1954 | !isSignedIn || | |
| 1955 | primaryCloudLinkState.data === null || | |
| 1956 | primaryCloudLinkState.isPending || | |
| 1957 | isBusy | |
| 1958 | } | |
| 1887 | 1959 | disabledReason={disabledReason} |
| 1888 | 1960 | onCheckedChange={(enabled) => void updateManagedTunnel(enabled)} |
| 1889 | 1961 | /> |
| ... | ... | @@ -1897,7 +1969,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1897 | 1969 | <CloudLinkSwitch |
| 1898 | 1970 | ariaLabel="Publish agent activity to mobile clients" |
| 1899 | 1971 | checked={publishAgentActivity} |
| 1900 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy} | |
| 1972 | disabled={ | |
| 1973 | !canManageRelay || | |
| 1974 | !isSignedIn || | |
| 1975 | primaryCloudLinkState.data === null || | |
| 1976 | primaryCloudLinkState.isPending || | |
| 1977 | isBusy | |
| 1978 | } | |
| 1901 | 1979 | disabledReason={disabledReason} |
| 1902 | 1980 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} |
| 1903 | 1981 | /> |
| ... | ... | @@ -1922,8 +2000,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1922 | 2000 | ); |
| 1923 | 2001 | } |
| 1924 | 2002 | |
| 1925 | function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { | |
| 1926 | return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null; | |
| 2003 | function CloudLinkRow({ | |
| 2004 | canReadRelay, | |
| 2005 | canManageRelay, | |
| 2006 | }: { | |
| 2007 | readonly canReadRelay: boolean; | |
| 2008 | readonly canManageRelay: boolean; | |
| 2009 | }) { | |
| 2010 | if (!hasCloudPublicConfig()) return null; | |
| 2011 | if (!canReadRelay) { | |
| 2012 | return ( | |
| 2013 | <SettingsRow | |
| 2014 | title="T3 Connect" | |
| 2015 | description="To edit these settings, pair this connection with both View relay and Manage relay permissions." | |
| 2016 | /> | |
| 2017 | ); | |
| 2018 | } | |
| 2019 | return <ConfiguredCloudLinkRow canManageRelay={canManageRelay} />; | |
| 1927 | 2020 | } |
| 1928 | 2021 | |
| 1929 | 2022 | function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { |
| ... | ... | @@ -1981,11 +2074,9 @@ export function ConnectionsSettings() { |
| 1981 | 2074 | }); |
| 1982 | 2075 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1983 | 2076 | const primarySessionState = usePrimarySessionState(); |
| 1984 | const currentSessionScopes = desktopBridge | |
| 1985 | ? AuthAdministrativeScopes | |
| 1986 | : primarySessionState.data?.authenticated | |
| 1987 | ? (primarySessionState.data.scopes ?? null) | |
| 1988 | : null; | |
| 2077 | const currentSessionScopes = primarySessionState.data?.authenticated | |
| 2078 | ? (primarySessionState.data.scopes ?? null) | |
| 2079 | : null; | |
| 1989 | 2080 | const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); |
| 1990 | 2081 | // Catalog order is the order the machines were added; rows never jump when |
| 1991 | 2082 | // one is switched off. |
| ... | ... | @@ -2165,12 +2256,14 @@ export function ConnectionsSettings() { |
| 2165 | 2256 | const setDefaultAdvertisedEndpointKey = useUiStateStore( |
| 2166 | 2257 | (state) => state.setDefaultAdvertisedEndpointKey, |
| 2167 | 2258 | ); |
| 2168 | const canManageLocalBackend = | |
| 2169 | !isLocalEnvironmentDisabled() && | |
| 2170 | (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false); | |
| 2171 | const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; | |
| 2259 | const canReadAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessReadScope); | |
| 2260 | const canWriteAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope); | |
| 2261 | const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope); | |
| 2262 | const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); | |
| 2263 | const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); | |
| 2264 | const canManageLocalBackend = !isLocalEnvironmentDisabled() && canMaintain; | |
| 2172 | 2265 | const authAccessChanges = useEnvironmentQuery( |
| 2173 | canManageLocalBackend && primaryEnvironmentId !== null | |
| 2266 | canReadAccess && primaryEnvironmentId !== null | |
| 2174 | 2267 | ? authEnvironment.accessChanges({ |
| 2175 | 2268 | environmentId: primaryEnvironmentId, |
| 2176 | 2269 | input: null, |
| ... | ... | @@ -2273,7 +2366,12 @@ export function ConnectionsSettings() { |
| 2273 | 2366 | |
| 2274 | 2367 | const handleDesktopServerExposureChange = useCallback( |
| 2275 | 2368 | async (checked: boolean) => { |
| 2276 | if (!desktopBridge) return; | |
| 2369 | if ( | |
| 2370 | !desktopBridge || | |
| 2371 | primaryEnvironmentId === null || | |
| 2372 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) | |
| 2373 | ) | |
| 2374 | return; | |
| 2277 | 2375 | setIsUpdatingDesktopServerExposure(true); |
| 2278 | 2376 | setDesktopServerExposureMutationError(null); |
| 2279 | 2377 | try { |
| ... | ... | @@ -2296,7 +2394,7 @@ export function ConnectionsSettings() { |
| 2296 | 2394 | setIsUpdatingDesktopServerExposure(false); |
| 2297 | 2395 | } |
| 2298 | 2396 | }, |
| 2299 | [desktopBridge], | |
| 2397 | [desktopBridge, primaryEnvironmentId], | |
| 2300 | 2398 | ); |
| 2301 | 2399 | |
| 2302 | 2400 | const handleConfirmDesktopServerExposureChange = useCallback(() => { |
| ... | ... | @@ -2306,7 +2404,12 @@ export function ConnectionsSettings() { |
| 2306 | 2404 | }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); |
| 2307 | 2405 | |
| 2308 | 2406 | const handleConfirmTailscaleServeSetup = useCallback(async () => { |
| 2309 | if (!desktopBridge) return; | |
| 2407 | if ( | |
| 2408 | !desktopBridge || | |
| 2409 | primaryEnvironmentId === null || | |
| 2410 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) | |
| 2411 | ) | |
| 2412 | return; | |
| 2310 | 2413 | if (!isTailscaleServePortValid) return; |
| 2311 | 2414 | setIsUpdatingTailscaleServe(true); |
| 2312 | 2415 | setDesktopServerExposureMutationError(null); |
| ... | ... | @@ -2331,7 +2434,7 @@ export function ConnectionsSettings() { |
| 2331 | 2434 | } finally { |
| 2332 | 2435 | setIsUpdatingTailscaleServe(false); |
| 2333 | 2436 | } |
| 2334 | }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]); | |
| 2437 | }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort, primaryEnvironmentId]); | |
| 2335 | 2438 | |
| 2336 | 2439 | const handleStartTailscaleServeSetup = useCallback( |
| 2337 | 2440 | (endpoint: AdvertisedEndpoint) => { |
| ... | ... | @@ -2344,7 +2447,12 @@ export function ConnectionsSettings() { |
| 2344 | 2447 | ); |
| 2345 | 2448 | |
| 2346 | 2449 | const handleConfirmTailscaleServeDisable = useCallback(async () => { |
| 2347 | if (!desktopBridge) return; | |
| 2450 | if ( | |
| 2451 | !desktopBridge || | |
| 2452 | primaryEnvironmentId === null || | |
| 2453 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) | |
| 2454 | ) | |
| 2455 | return; | |
| 2348 | 2456 | setIsUpdatingTailscaleServe(true); |
| 2349 | 2457 | setDesktopServerExposureMutationError(null); |
| 2350 | 2458 | try { |
| ... | ... | @@ -2367,34 +2475,47 @@ export function ConnectionsSettings() { |
| 2367 | 2475 | } finally { |
| 2368 | 2476 | setIsUpdatingTailscaleServe(false); |
| 2369 | 2477 | } |
| 2370 | }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]); | |
| 2478 | }, [desktopBridge, desktopServerExposureState, primaryEnvironmentId]); | |
| 2371 | 2479 | |
| 2372 | 2480 | const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => { |
| 2373 | 2481 | setDisableTailscaleServeDialogOpen(true); |
| 2374 | 2482 | }, []); |
| 2375 | 2483 | |
| 2376 | const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { | |
| 2377 | setRevokingDesktopPairingLinkId(id); | |
| 2378 | setDesktopAccessManagementMutationError(null); | |
| 2379 | try { | |
| 2380 | await revokeServerPairingLink(id); | |
| 2381 | } catch (error) { | |
| 2382 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; | |
| 2383 | setDesktopAccessManagementMutationError(message); | |
| 2384 | toastManager.add( | |
| 2385 | stackedThreadToast({ | |
| 2386 | type: "error", | |
| 2387 | title: "Could not revoke pairing link", | |
| 2388 | description: message, | |
| 2389 | }), | |
| 2390 | ); | |
| 2391 | } finally { | |
| 2392 | setRevokingDesktopPairingLinkId(null); | |
| 2393 | } | |
| 2394 | }, []); | |
| 2484 | const handleRevokeDesktopPairingLink = useCallback( | |
| 2485 | async (id: string) => { | |
| 2486 | if ( | |
| 2487 | primaryEnvironmentId === null || | |
| 2488 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) | |
| 2489 | ) | |
| 2490 | return; | |
| 2491 | setRevokingDesktopPairingLinkId(id); | |
| 2492 | setDesktopAccessManagementMutationError(null); | |
| 2493 | try { | |
| 2494 | await revokeServerPairingLink(id); | |
| 2495 | } catch (error) { | |
| 2496 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; | |
| 2497 | setDesktopAccessManagementMutationError(message); | |
| 2498 | toastManager.add( | |
| 2499 | stackedThreadToast({ | |
| 2500 | type: "error", | |
| 2501 | title: "Could not revoke pairing link", | |
| 2502 | description: message, | |
| 2503 | }), | |
| 2504 | ); | |
| 2505 | } finally { | |
| 2506 | setRevokingDesktopPairingLinkId(null); | |
| 2507 | } | |
| 2508 | }, | |
| 2509 | [primaryEnvironmentId], | |
| 2510 | ); | |
| 2395 | 2511 | |
| 2396 | 2512 | const handleRevokeDesktopClientSession = useCallback( |
| 2397 | 2513 | async (sessionId: ServerClientSessionRecord["sessionId"]) => { |
| 2514 | if ( | |
| 2515 | primaryEnvironmentId === null || | |
| 2516 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) | |
| 2517 | ) | |
| 2518 | return; | |
| 2398 | 2519 | setRevokingDesktopClientSessionId(sessionId); |
| 2399 | 2520 | setDesktopAccessManagementMutationError(null); |
| 2400 | 2521 | try { |
| ... | ... | @@ -2413,10 +2534,15 @@ export function ConnectionsSettings() { |
| 2413 | 2534 | setRevokingDesktopClientSessionId(null); |
| 2414 | 2535 | } |
| 2415 | 2536 | }, |
| 2416 | [], | |
| 2537 | [primaryEnvironmentId], | |
| 2417 | 2538 | ); |
| 2418 | 2539 | |
| 2419 | 2540 | const handleRevokeOtherDesktopClients = useCallback(async () => { |
| 2541 | if ( | |
| 2542 | primaryEnvironmentId === null || | |
| 2543 | !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) | |
| 2544 | ) | |
| 2545 | return; | |
| 2420 | 2546 | setIsRevokingOtherDesktopClients(true); |
| 2421 | 2547 | setDesktopAccessManagementMutationError(null); |
| 2422 | 2548 | try { |
| ... | ... | @@ -2439,7 +2565,7 @@ export function ConnectionsSettings() { |
| 2439 | 2565 | } finally { |
| 2440 | 2566 | setIsRevokingOtherDesktopClients(false); |
| 2441 | 2567 | } |
| 2442 | }, []); | |
| 2568 | }, [primaryEnvironmentId]); | |
| 2443 | 2569 | |
| 2444 | 2570 | // Shared by manual SSH submission and discovered-host selection. |
| 2445 | 2571 | const connectSavedBackendSshTarget = useCallback( |
| ... | ... | @@ -2743,8 +2869,6 @@ export function ConnectionsSettings() { |
| 2743 | 2869 | : visibleDesktopNetworkAdvertisedEndpoints, |
| 2744 | 2870 | [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints], |
| 2745 | 2871 | ); |
| 2746 | const isLocalBackendRemotelyReachable = | |
| 2747 | isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available"; | |
| 2748 | 2872 | const defaultDesktopNetworkAdvertisedEndpoint = useMemo( |
| 2749 | 2873 | () => |
| 2750 | 2874 | selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey), |
| ... | ... | @@ -3074,7 +3198,12 @@ export function ConnectionsSettings() { |
| 3074 | 3198 | // entry catches up (registers/unregisters) without a reload. |
| 3075 | 3199 | const applyWslSettingChange = useCallback( |
| 3076 | 3200 | async (apply: () => Promise<DesktopWslState>) => { |
| 3077 | if (!desktopBridge) return; | |
| 3201 | if ( | |
| 3202 | !desktopBridge || | |
| 3203 | primaryEnvironmentId === null || | |
| 3204 | !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope) | |
| 3205 | ) | |
| 3206 | return; | |
| 3078 | 3207 | setIsUpdatingWslBackend(true); |
| 3079 | 3208 | setDesktopWslMutationError(null); |
| 3080 | 3209 | try { |
| ... | ... | @@ -3099,7 +3228,7 @@ export function ConnectionsSettings() { |
| 3099 | 3228 | setIsUpdatingWslBackend(false); |
| 3100 | 3229 | } |
| 3101 | 3230 | }, |
| 3102 | [desktopBridge], | |
| 3231 | [desktopBridge, primaryEnvironmentId], | |
| 3103 | 3232 | ); |
| 3104 | 3233 | |
| 3105 | 3234 | // Reload the keep-alive WSL state atom. Clearing the mutation error before |
| ... | ... | @@ -3428,6 +3557,7 @@ export function ConnectionsSettings() { |
| 3428 | 3557 | revokingClientSessionId={revokingDesktopClientSessionId} |
| 3429 | 3558 | onRevokePairingLink={handleRevokeDesktopPairingLink} |
| 3430 | 3559 | onRevokeClientSession={handleRevokeDesktopClientSession} |
| 3560 | canRevoke={canWriteAccess} | |
| 3431 | 3561 | /> |
| 3432 | 3562 | </> |
| 3433 | 3563 | ); |
| ... | ... | @@ -3467,35 +3597,42 @@ export function ConnectionsSettings() { |
| 3467 | 3597 | <SettingsRow |
| 3468 | 3598 | title={searchableSetting("network-access").title} |
| 3469 | 3599 | description={ |
| 3470 | currentAuthPolicy === "remote-reachable" | |
| 3471 | ? "Remote access is already configured. Change network exposure where the server starts." | |
| 3472 | : "Only this machine can connect. Restart with a non-loopback host for remote pairing." | |
| 3600 | desktopBridge | |
| 3601 | ? "This connection cannot view or change network exposure." | |
| 3602 | : currentAuthPolicy === "remote-reachable" | |
| 3603 | ? "Remote access is already configured. Change network exposure where the server starts." | |
| 3604 | : currentAuthPolicy === "loopback-browser" | |
| 3605 | ? "Only this machine can connect. Restart with a non-loopback host for remote pairing." | |
| 3606 | : "Network exposure information is unavailable." | |
| 3473 | 3607 | } |
| 3474 | 3608 | control={ |
| 3475 | <Tooltip> | |
| 3476 | <TooltipTrigger | |
| 3477 | render={ | |
| 3478 | <span className="inline-flex"> | |
| 3479 | <Switch | |
| 3480 | checked={isLocalBackendNetworkAccessible} | |
| 3481 | disabled | |
| 3482 | aria-label="Enable network access" | |
| 3483 | /> | |
| 3484 | </span> | |
| 3485 | } | |
| 3486 | /> | |
| 3487 | <TooltipPopup side="top"> | |
| 3488 | Network exposure changes restart the backend and must be controlled where the server | |
| 3489 | process is launched. | |
| 3490 | </TooltipPopup> | |
| 3491 | </Tooltip> | |
| 3609 | !desktopBridge && | |
| 3610 | (currentAuthPolicy === "remote-reachable" || currentAuthPolicy === "loopback-browser") ? ( | |
| 3611 | <Tooltip> | |
| 3612 | <TooltipTrigger | |
| 3613 | render={ | |
| 3614 | <span className="inline-flex"> | |
| 3615 | <Switch | |
| 3616 | checked={isLocalBackendNetworkAccessible} | |
| 3617 | disabled | |
| 3618 | aria-label="Enable network access" | |
| 3619 | /> | |
| 3620 | </span> | |
| 3621 | } | |
| 3622 | /> | |
| 3623 | <TooltipPopup side="top"> | |
| 3624 | Network exposure changes restart the backend and must be controlled where the server | |
| 3625 | process is launched. | |
| 3626 | </TooltipPopup> | |
| 3627 | </Tooltip> | |
| 3628 | ) : undefined | |
| 3492 | 3629 | } |
| 3493 | 3630 | /> |
| 3494 | 3631 | ); |
| 3495 | 3632 | |
| 3496 | 3633 | const primarySettings = ( |
| 3497 | 3634 | <> |
| 3498 | {desktopBridge || canManageLocalBackend ? ( | |
| 3635 | {primaryEnvironmentId !== null ? ( | |
| 3499 | 3636 | <> |
| 3500 | 3637 | <SettingsSection |
| 3501 | 3638 | {...searchableSetting("connections-environment")} |
| ... | ... | @@ -3591,17 +3728,21 @@ export function ConnectionsSettings() { |
| 3591 | 3728 | {renderEndpointRows("endpoint-rail")} |
| 3592 | 3729 | {renderTailscaleRow()} |
| 3593 | 3730 | {renderWslRow()} |
| 3594 | <CloudLinkRow canManageRelay={canManageRelay} /> | |
| 3731 | {canReadRelay || canManageRelay ? ( | |
| 3732 | <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} /> | |
| 3733 | ) : null} | |
| 3595 | 3734 | </> |
| 3596 | 3735 | ) : canManageLocalBackend ? ( |
| 3597 | 3736 | <> |
| 3598 | 3737 | {renderDisabledNetworkAccessRow()} |
| 3599 | <CloudLinkRow canManageRelay={canManageRelay} /> | |
| 3738 | {canReadRelay || canManageRelay ? ( | |
| 3739 | <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} /> | |
| 3740 | ) : null} | |
| 3600 | 3741 | </> |
| 3601 | 3742 | ) : null} |
| 3602 | 3743 | </SettingsSection> |
| 3603 | 3744 | |
| 3604 | {isLocalBackendRemotelyReachable ? ( | |
| 3745 | {canReadAccess || canWriteAccess ? ( | |
| 3605 | 3746 | <FoldedSettingsSection |
| 3606 | 3747 | id="authorized-clients" |
| 3607 | 3748 | title="Authorized clients" |
| ... | ... | @@ -3610,12 +3751,21 @@ export function ConnectionsSettings() { |
| 3610 | 3751 | visibleDesktopPairingLinks, |
| 3611 | 3752 | )} |
| 3612 | 3753 | control={ |
| 3613 | <AuthorizedClientsHeaderAction | |
| 3614 | onPairingLinkCreated={handlePairingLinkCreated} | |
| 3615 | clientSessions={desktopClientSessions} | |
| 3616 | isRevokingOtherClients={isRevokingOtherDesktopClients} | |
| 3617 | onRevokeOtherClients={handleRevokeOtherDesktopClients} | |
| 3618 | /> | |
| 3754 | canWriteAccess ? ( | |
| 3755 | <AuthorizedClientsHeaderAction | |
| 3756 | delegatableScopes={currentSessionScopes ?? []} | |
| 3757 | onPairingLinkCreated={handlePairingLinkCreated} | |
| 3758 | clientSessions={ | |
| 3759 | canReadAccess && | |
| 3760 | authAccessChanges.error === null && | |
| 3761 | authAccessChanges.data?.type === "snapshot" | |
| 3762 | ? desktopClientSessions | |
| 3763 | : null | |
| 3764 | } | |
| 3765 | isRevokingOtherClients={isRevokingOtherDesktopClients} | |
| 3766 | onRevokeOtherClients={handleRevokeOtherDesktopClients} | |
| 3767 | /> | |
| 3768 | ) : undefined | |
| 3619 | 3769 | } |
| 3620 | 3770 | > |
| 3621 | 3771 | <ScrollArea |
| ... | ... | @@ -3624,12 +3774,18 @@ export function ConnectionsSettings() { |
| 3624 | 3774 | className="max-h-[22.5rem]" |
| 3625 | 3775 | data-testid="authorized-clients-scroll-area" |
| 3626 | 3776 | > |
| 3627 | {renderAuthorizedClients("current")} | |
| 3777 | {canReadAccess ? ( | |
| 3778 | renderAuthorizedClients("current") | |
| 3779 | ) : ( | |
| 3780 | <p className="px-4 py-3 text-xs text-muted-foreground"> | |
| 3781 | This connection can create access links but cannot view authorized clients. | |
| 3782 | </p> | |
| 3783 | )} | |
| 3628 | 3784 | </ScrollArea> |
| 3629 | 3785 | </FoldedSettingsSection> |
| 3630 | 3786 | ) : null} |
| 3631 | 3787 | <AlertDialog |
| 3632 | open={isDesktopServerExposureDialogOpen} | |
| 3788 | open={isDesktopServerExposureDialogOpen && canManageLocalBackend} | |
| 3633 | 3789 | onOpenChange={(open) => { |
| 3634 | 3790 | if (isUpdatingDesktopServerExposure) return; |
| 3635 | 3791 | setIsDesktopServerExposureDialogOpen(open); |
| ... | ... | @@ -3662,7 +3818,9 @@ export function ConnectionsSettings() { |
| 3662 | 3818 | variant="default" |
| 3663 | 3819 | onClick={handleConfirmDesktopServerExposureChange} |
| 3664 | 3820 | disabled={ |
| 3665 | pendingDesktopServerExposureMode === null || isUpdatingDesktopServerExposure | |
| 3821 | !canManageLocalBackend || | |
| 3822 | pendingDesktopServerExposureMode === null || | |
| 3823 | isUpdatingDesktopServerExposure | |
| 3666 | 3824 | } |
| 3667 | 3825 | > |
| 3668 | 3826 | {isUpdatingDesktopServerExposure && <Spinner size="sm" />} |
| ... | ... | @@ -3678,7 +3836,7 @@ export function ConnectionsSettings() { |
| 3678 | 3836 | </AlertDialogPopup> |
| 3679 | 3837 | </AlertDialog> |
| 3680 | 3838 | <AlertDialog |
| 3681 | open={isWslConfirmDialogOpen} | |
| 3839 | open={isWslConfirmDialogOpen && canManageLocalBackend} | |
| 3682 | 3840 | onOpenChange={(open) => { |
| 3683 | 3841 | if (isUpdatingWslBackend) return; |
| 3684 | 3842 | if (!open) setPendingWslChange(null); |
| ... | ... | @@ -3725,7 +3883,7 @@ export function ConnectionsSettings() { |
| 3725 | 3883 | <Button |
| 3726 | 3884 | variant="outline" |
| 3727 | 3885 | onClick={() => handleConfirmEnableWsl("wsl-only")} |
| 3728 | disabled={isUpdatingWslBackend} | |
| 3886 | disabled={isUpdatingWslBackend || !canManageLocalBackend} | |
| 3729 | 3887 | > |
| 3730 | 3888 | {isUpdatingWslBackend ? ( |
| 3731 | 3889 | <> |
| ... | ... | @@ -3739,7 +3897,7 @@ export function ConnectionsSettings() { |
| 3739 | 3897 | <Button |
| 3740 | 3898 | variant="default" |
| 3741 | 3899 | onClick={() => handleConfirmEnableWsl("both")} |
| 3742 | disabled={isUpdatingWslBackend} | |
| 3900 | disabled={isUpdatingWslBackend || !canManageLocalBackend} | |
| 3743 | 3901 | > |
| 3744 | 3902 | {isUpdatingWslBackend ? ( |
| 3745 | 3903 | <> |
| ... | ... | @@ -3760,7 +3918,7 @@ export function ConnectionsSettings() { |
| 3760 | 3918 | : "default" |
| 3761 | 3919 | } |
| 3762 | 3920 | onClick={handleConfirmWslChange} |
| 3763 | disabled={isUpdatingWslBackend} | |
| 3921 | disabled={isUpdatingWslBackend || !canManageLocalBackend} | |
| 3764 | 3922 | > |
| 3765 | 3923 | {isUpdatingWslBackend ? ( |
| 3766 | 3924 | <> |
| ... | ... | @@ -3786,7 +3944,7 @@ export function ConnectionsSettings() { |
| 3786 | 3944 | </AlertDialogPopup> |
| 3787 | 3945 | </AlertDialog> |
| 3788 | 3946 | <AlertDialog |
| 3789 | open={disableTailscaleServeDialogOpen} | |
| 3947 | open={disableTailscaleServeDialogOpen && canManageLocalBackend} | |
| 3790 | 3948 | onOpenChange={(open) => { |
| 3791 | 3949 | if (isUpdatingTailscaleServe) return; |
| 3792 | 3950 | setDisableTailscaleServeDialogOpen(open); |
| ... | ... | @@ -3809,7 +3967,7 @@ export function ConnectionsSettings() { |
| 3809 | 3967 | <Button |
| 3810 | 3968 | variant="destructive" |
| 3811 | 3969 | onClick={() => void handleConfirmTailscaleServeDisable()} |
| 3812 | disabled={isUpdatingTailscaleServe} | |
| 3970 | disabled={isUpdatingTailscaleServe || !canManageLocalBackend} | |
| 3813 | 3971 | > |
| 3814 | 3972 | {isUpdatingTailscaleServe ? ( |
| 3815 | 3973 | <> |
| ... | ... | @@ -3899,10 +4057,12 @@ export function ConnectionsSettings() { |
| 3899 | 4057 | ) : ( |
| 3900 | 4058 | <SettingsSection {...searchableSetting("connections-environment")}> |
| 3901 | 4059 | <SettingsRow |
| 3902 | title="Administrative access" | |
| 3903 | description="Pairing links and client-session management require the access:write scope for this backend." | |
| 4060 | title="No environment selected" | |
| 4061 | description="Connect an environment to view its settings and access." | |
| 3904 | 4062 | /> |
| 3905 | <CloudLinkRow canManageRelay={canManageRelay} /> | |
| 4063 | {canReadRelay || canManageRelay ? ( | |
| 4064 | <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} /> | |
| 4065 | ) : null} | |
| 3906 | 4066 | </SettingsSection> |
| 3907 | 4067 | )} |
| 3908 | 4068 | </> |
apps/web/src/components/settings/DeviceHostsSettings.tsx+10-2| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; |
| 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 3 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 2 | 4 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| 3 | 5 | import { AppleIcon, AndroidIcon } from "../Icons"; |
| 4 | 6 | import { Spinner } from "../ui/spinner"; |
| ... | ... | @@ -22,6 +24,7 @@ import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; |
| 22 | 24 | |
| 23 | 25 | export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { |
| 24 | 26 | const { scope, environments, connectedEnvironments } = useSettingsScope(); |
| 27 | const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); | |
| 25 | 28 | const projectScope = scope.kind === "project" || scope.kind === "checkout"; |
| 26 | 29 | const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); |
| 27 | 30 | const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null); |
| ... | ... | @@ -42,6 +45,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 42 | 45 | if (environment.connection.phase !== "connected" || !environment.serverConfig) { |
| 43 | 46 | throw new Error("Environment disconnected"); |
| 44 | 47 | } |
| 48 | if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) { | |
| 49 | throw new Error("This connection cannot change device settings."); | |
| 50 | } | |
| 45 | 51 | return update({ |
| 46 | 52 | environmentId: environment.environmentId, |
| 47 | 53 | input: { |
| ... | ... | @@ -85,7 +91,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 85 | 91 | <Button |
| 86 | 92 | size="sm" |
| 87 | 93 | variant="outline" |
| 88 | disabled={projectScope || busy || !props.environmentId || editing !== null} | |
| 94 | disabled={ | |
| 95 | !canConfigure || projectScope || busy || !props.environmentId || editing !== null | |
| 96 | } | |
| 89 | 97 | onClick={() => { |
| 90 | 98 | setOriginalHost(null); |
| 91 | 99 | setEditing({ id: randomUUID(), label: "", target: "" }); |
| ... | ... | @@ -113,7 +121,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null |
| 113 | 121 | environmentLabel={environment.label} |
| 114 | 122 | environmentId={environment.environmentId} |
| 115 | 123 | hosts={environment.serverConfig?.settings.deviceHosts ?? []} |
| 116 | busy={projectScope || busy} | |
| 124 | busy={!canConfigure || projectScope || busy} | |
| 117 | 125 | checks={checks} |
| 118 | 126 | testConnection={async (host) => { |
| 119 | 127 | const results = await testConnection(host); |
apps/web/src/components/settings/DiagnosticsSettings.tsx+34-23| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | 1 | import { ProcessSignalActions } from "./ProcessSignalActions"; |
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 3 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 4 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; | |
| 2 | 5 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 3 | 6 | import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon } from "lucide-react"; |
| 4 | 7 | import { ChevronDown, ChevronRight } from "lucide"; |
| ... | ... | @@ -17,7 +20,7 @@ import * as Option from "effect/Option"; |
| 17 | 20 | |
| 18 | 21 | import { cn } from "../../lib/utils"; |
| 19 | 22 | import { ensureLocalApi } from "../../localApi"; |
| 20 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | |
| 23 | import { useOpenInPreferredEditor } from "../../editorPreferences"; | |
| 21 | 24 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 22 | 25 | import { useEnvironmentQuery } from "../../state/query"; |
| 23 | 26 | import { serverEnvironment } from "../../state/server"; |
| ... | ... | @@ -296,11 +299,13 @@ function ProcessNameCell({ |
| 296 | 299 | |
| 297 | 300 | function ProcessDiagnosticsTable({ |
| 298 | 301 | processes, |
| 302 | canMaintainEnvironment, | |
| 299 | 303 | signalingPid, |
| 300 | 304 | onSignal, |
| 301 | 305 | emptyLabel, |
| 302 | 306 | }: { |
| 303 | 307 | processes: ReadonlyArray<ServerProcessDiagnosticsEntry>; |
| 308 | canMaintainEnvironment: boolean; | |
| 304 | 309 | signalingPid: number | null; |
| 305 | 310 | onSignal: (pid: number, signal: ServerProcessSignal) => void; |
| 306 | 311 | emptyLabel?: string; |
| ... | ... | @@ -408,7 +413,7 @@ function ProcessDiagnosticsTable({ |
| 408 | 413 | </td> |
| 409 | 414 | <td className="p-2 align-middle sm:pr-4"> |
| 410 | 415 | <ProcessSignalActions |
| 411 | disabled={signalingPid === process.pid} | |
| 416 | disabled={!canMaintainEnvironment || signalingPid === process.pid} | |
| 412 | 417 | onSignal={(signal) => onSignal(process.pid, signal)} |
| 413 | 418 | /> |
| 414 | 419 | </td> |
| ... | ... | @@ -710,14 +715,14 @@ export function DiagnosticsSettingsPanel() { |
| 710 | 715 | // The boundary only mounts this page when the selection resolves to one |
| 711 | 716 | // connected environment, so the representative is the one to inspect. |
| 712 | 717 | const environmentId = environment?.environmentId ?? null; |
| 718 | const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); | |
| 713 | 719 | const observability = environment?.serverConfig?.observability; |
| 714 | 720 | const availableEditors = environment?.serverConfig?.availableEditors; |
| 721 | const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 715 | 722 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { |
| 716 | 723 | reportFailure: false, |
| 717 | 724 | }); |
| 718 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { | |
| 719 | reportFailure: false, | |
| 720 | }); | |
| 725 | const openInEditor = useOpenInPreferredEditor(environmentId, availableEditors ?? []); | |
| 721 | 726 | const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); |
| 722 | 727 | const selectedResourceWindow = |
| 723 | 728 | RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? |
| ... | ... | @@ -773,26 +778,17 @@ export function DiagnosticsSettingsPanel() { |
| 773 | 778 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; |
| 774 | 779 | if (!logsDirectoryPath) return; |
| 775 | 780 | |
| 776 | const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); | |
| 777 | if (!editor) { | |
| 778 | setOpenLogsDirectoryError("No available editors found."); | |
| 779 | return; | |
| 780 | } | |
| 781 | if (environmentId === null) { | |
| 782 | setOpenLogsDirectoryError("No environment is selected."); | |
| 781 | if ( | |
| 782 | environmentId === null || | |
| 783 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) | |
| 784 | ) { | |
| 783 | 785 | return; |
| 784 | 786 | } |
| 785 | 787 | |
| 786 | 788 | setIsOpeningLogsDirectory(true); |
| 787 | 789 | setOpenLogsDirectoryError(null); |
| 788 | 790 | void (async () => { |
| 789 | const result = await openInEditor({ | |
| 790 | environmentId, | |
| 791 | input: { | |
| 792 | cwd: logsDirectoryPath, | |
| 793 | editor, | |
| 794 | }, | |
| 795 | }); | |
| 791 | const result = await openInEditor(logsDirectoryPath); | |
| 796 | 792 | setIsOpeningLogsDirectory(false); |
| 797 | 793 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 798 | 794 | const error = squashAtomCommandFailure(result); |
| ... | ... | @@ -801,7 +797,7 @@ export function DiagnosticsSettingsPanel() { |
| 801 | 797 | ); |
| 802 | 798 | } |
| 803 | 799 | })(); |
| 804 | }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); | |
| 800 | }, [environmentId, observability?.logsDirectoryPath, openInEditor]); | |
| 805 | 801 | |
| 806 | 802 | const isInitialLoading = isPending && data === null; |
| 807 | 803 | const isProcessInitialLoading = isProcessPending && processData === null; |
| ... | ... | @@ -809,7 +805,12 @@ export function DiagnosticsSettingsPanel() { |
| 809 | 805 | async (pid: number, signal: ServerProcessSignal) => { |
| 810 | 806 | const targetEnvironmentId = environmentIdRef.current; |
| 811 | 807 | const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); |
| 812 | if (targetEnvironmentId === null || process === undefined) return; | |
| 808 | if ( | |
| 809 | targetEnvironmentId === null || | |
| 810 | process === undefined || | |
| 811 | !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope) | |
| 812 | ) | |
| 813 | return; | |
| 813 | 814 | if (signalingPidRef.current !== null) return; |
| 814 | 815 | signalingPidRef.current = pid; |
| 815 | 816 | setSignalingPid(pid); |
| ... | ... | @@ -838,7 +839,12 @@ export function DiagnosticsSettingsPanel() { |
| 838 | 839 | return; |
| 839 | 840 | } |
| 840 | 841 | } |
| 841 | if (environmentIdRef.current !== targetEnvironmentId) { | |
| 842 | const currentEnvironmentId = environmentIdRef.current; | |
| 843 | if ( | |
| 844 | currentEnvironmentId === null || | |
| 845 | currentEnvironmentId !== targetEnvironmentId || | |
| 846 | !readEnvironmentScope(currentEnvironmentId, AuthEnvironmentMaintainScope) | |
| 847 | ) { | |
| 842 | 848 | clearSignaling(); |
| 843 | 849 | return; |
| 844 | 850 | } |
| ... | ... | @@ -956,6 +962,7 @@ export function DiagnosticsSettingsPanel() { |
| 956 | 962 | ) : null} |
| 957 | 963 | <ProcessDiagnosticsTable |
| 958 | 964 | processes={processData?.processes ?? []} |
| 965 | canMaintainEnvironment={canMaintainEnvironment} | |
| 959 | 966 | signalingPid={signalingPid} |
| 960 | 967 | onSignal={signalProcess} |
| 961 | 968 | emptyLabel={ |
| ... | ... | @@ -1041,7 +1048,11 @@ export function DiagnosticsSettingsPanel() { |
| 1041 | 1048 | <Button |
| 1042 | 1049 | size="icon-xs" |
| 1043 | 1050 | variant="ghost-muted" |
| 1044 | disabled={!observability?.logsDirectoryPath || isOpeningLogsDirectory} | |
| 1051 | disabled={ | |
| 1052 | !canOpenHostEditor || | |
| 1053 | !observability?.logsDirectoryPath || | |
| 1054 | isOpeningLogsDirectory | |
| 1055 | } | |
| 1045 | 1056 | onClick={openLogsDirectory} |
| 1046 | 1057 | aria-label="Open logs folder" |
| 1047 | 1058 | > |
apps/web/src/components/settings/EnvironmentIconPicker.test.ts+2-2| ... | ... | @@ -30,10 +30,10 @@ describe("resolveEnvironmentIconPickerLock", () => { |
| 30 | 30 | ).toMatch(/cannot change/); |
| 31 | 31 | }); |
| 32 | 32 | |
| 33 | it("stays open while access is still resolving so a slow session does not flicker", () => { | |
| 33 | it("waits for a settings grant before allowing changes", () => { | |
| 34 | 34 | expect( |
| 35 | 35 | resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }), |
| 36 | ).toBeNull(); | |
| 36 | ).toMatch(/cannot change/); | |
| 37 | 37 | expect( |
| 38 | 38 | resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }), |
| 39 | 39 | ).toBeNull(); |
apps/web/src/components/settings/EnvironmentIconPicker.tsx+6-35| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | AuthSettingsWriteScope, | |
| 2 | 3 | ENVIRONMENT_MACHINE_KINDS, |
| 3 | 4 | isEnvironmentMachineKind, |
| 4 | 5 | resolveEnvironmentMachineKind, |
| ... | ... | @@ -6,11 +7,8 @@ import { |
| 6 | 7 | type ServerConfig, |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | |
| 9 | import { isElectron } from "../../env"; | |
| 10 | import { usePrimarySessionState } from "../../environments/primary"; | |
| 11 | 10 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; |
| 12 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 13 | import { useEnvironmentSessionState } from "../../state/session"; | |
| 11 | import { useEnvironmentScope } from "../../state/session"; | |
| 14 | 12 | import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 15 | 13 | import { |
| 16 | 14 | MenuItem, |
| ... | ... | @@ -21,10 +19,6 @@ import { |
| 21 | 19 | MenuSubPopup, |
| 22 | 20 | MenuSubTrigger, |
| 23 | 21 | } from "../ui/menu"; |
| 24 | import { | |
| 25 | resolvePrimaryOperateAccess, | |
| 26 | resolveRemoteOperateAccess, | |
| 27 | } from "./ProviderSettingsPanel.logic"; | |
| 28 | 22 | |
| 29 | 23 | /** |
| 30 | 24 | * Why the picker is inert, in the order the user can do something about it. |
| ... | ... | @@ -40,37 +34,12 @@ export function resolveEnvironmentIconPickerLock(input: { |
| 40 | 34 | if (input.serverConfig.environment.capabilities.environmentIcon !== true) { |
| 41 | 35 | return "This environment's server is too old to keep an icon. Update it to choose one."; |
| 42 | 36 | } |
| 43 | if (input.operateAccess === "denied") { | |
| 37 | if (input.operateAccess !== "granted") { | |
| 44 | 38 | return "Your session on this environment cannot change its settings."; |
| 45 | 39 | } |
| 46 | 40 | return null; |
| 47 | 41 | } |
| 48 | 42 | |
| 49 | // Same split the provider settings use: the desktop app owns its primary | |
| 50 | // server outright, a browser session on the primary checks its cookie | |
| 51 | // session's scopes, and a remote checks the scopes its own server reports. | |
| 52 | function useEnvironmentOperateAccess(environmentId: EnvironmentId) { | |
| 53 | const isPrimary = usePrimaryEnvironmentId() === environmentId; | |
| 54 | const primarySession = usePrimarySessionState(); | |
| 55 | const remoteSession = useEnvironmentSessionState(environmentId); | |
| 56 | if (isPrimary) { | |
| 57 | return isElectron | |
| 58 | ? "granted" | |
| 59 | : resolvePrimaryOperateAccess({ | |
| 60 | isPrimary: true, | |
| 61 | hasDesktopBridge: false, | |
| 62 | session: primarySession.data, | |
| 63 | isPending: primarySession.isPending, | |
| 64 | hasError: primarySession.error !== null, | |
| 65 | }); | |
| 66 | } | |
| 67 | return resolveRemoteOperateAccess({ | |
| 68 | session: remoteSession.data, | |
| 69 | isPending: remoteSession.isPending, | |
| 70 | hasError: remoteSession.hasError, | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | 43 | /** |
| 75 | 44 | * "Icon" submenu for an environment's row menu. Lists the machine kinds with |
| 76 | 45 | * the server's own detection marked, so the user can tell whether detection |
| ... | ... | @@ -86,7 +55,9 @@ export function EnvironmentIconMenu({ |
| 86 | 55 | readonly serverConfig: ServerConfig | null; |
| 87 | 56 | }) { |
| 88 | 57 | const updateSettings = useUpdateEnvironmentSettings(environmentId); |
| 89 | const operateAccess = useEnvironmentOperateAccess(environmentId); | |
| 58 | const operateAccess = useEnvironmentScope(environmentId, AuthSettingsWriteScope) | |
| 59 | ? "granted" | |
| 60 | : "denied"; | |
| 90 | 61 | const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); |
| 91 | 62 | // With no detection the server falls back to "server", so picking that |
| 92 | 63 | // kind clears the override the same way picking the detected kind does. |
apps/web/src/components/settings/IntegrationsSettings.test.tsx+12| ... | ... | @@ -17,6 +17,16 @@ const { listBrowserImportSources } = vi.hoisted(() => ({ |
| 17 | 17 | listBrowserImportSources: vi.fn().mockResolvedValue([]), |
| 18 | 18 | })); |
| 19 | 19 | |
| 20 | vi.mock("../ui/tooltip", () => ({ | |
| 21 | Tooltip: ({ children }: { children: ReactNode }) => children, | |
| 22 | TooltipTrigger: ({ render, children }: { render: ReactNode; children: ReactNode }) => ( | |
| 23 | <> | |
| 24 | {render} | |
| 25 | {children} | |
| 26 | </> | |
| 27 | ), | |
| 28 | TooltipPopup: () => null, | |
| 29 | })); | |
| 20 | 30 | vi.mock("../preview/previewBridge", () => ({ |
| 21 | 31 | previewBridge: { listBrowserImportSources }, |
| 22 | 32 | })); |
| ... | ... | @@ -24,6 +34,8 @@ vi.mock("../../env", () => ({ isElectron: true })); |
| 24 | 34 | vi.mock("../../state/environments", () => ({ |
| 25 | 35 | useEnvironments: () => ({ environments: [], isReady: true }), |
| 26 | 36 | usePrimaryEnvironment: () => null, |
| 37 | // Settings rows resolve the primary grant before rendering server controls. | |
| 38 | usePrimaryEnvironmentId: () => null, | |
| 27 | 39 | })); |
| 28 | 40 | vi.mock("../../hooks/useSettings", async (importOriginal) => ({ |
| 29 | 41 | ...(await importOriginal<typeof import("../../hooks/useSettings")>()), |
apps/web/src/components/settings/IntegrationsSettings.tsx+16-2| ... | ... | @@ -12,6 +12,7 @@ import { DeviceHostsSettings } from "./DeviceHostsSettings"; |
| 12 | 12 | * @module IntegrationsSettings |
| 13 | 13 | */ |
| 14 | 14 | import { |
| 15 | AuthSettingsWriteScope, | |
| 15 | 16 | BrowserImportFailureReason, |
| 16 | 17 | BROWSER_PROFILE_MAX_COUNT, |
| 17 | 18 | type BrowserLinkTarget, |
| ... | ... | @@ -49,6 +50,7 @@ import { previewBridge } from "~/components/preview/previewBridge"; |
| 49 | 50 | import { cn, randomUUID } from "~/lib/utils"; |
| 50 | 51 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; |
| 51 | 52 | import { deviceEnvironment, useDeviceState } from "~/state/device"; |
| 53 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 52 | 54 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 53 | 55 | import { previewEnvironment } from "~/state/preview"; |
| 54 | 56 | import { useServerConfigs } from "~/state/entities"; |
| ... | ... | @@ -647,6 +649,7 @@ function DeviceIntegrationControls({ |
| 647 | 649 | enabled: boolean; |
| 648 | 650 | agentAccessEnabled: boolean; |
| 649 | 651 | }) { |
| 652 | const canConfigure = useEnvironmentScope(environmentId, AuthSettingsWriteScope); | |
| 650 | 653 | const { state, loaded } = useDeviceState(environmentId); |
| 651 | 654 | const { scope, environments, connectedEnvironments } = useSettingsScope(); |
| 652 | 655 | const updateSettings = useUpdateScopedSettings(); |
| ... | ... | @@ -679,7 +682,7 @@ function DeviceIntegrationControls({ |
| 679 | 682 | kind: NonNullable<typeof pending>, |
| 680 | 683 | input: { enabled?: boolean; agentAccessEnabled?: boolean }, |
| 681 | 684 | ) => { |
| 682 | if (!environmentId) return; | |
| 685 | if (!environmentId || !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return; | |
| 683 | 686 | setPending(kind); |
| 684 | 687 | try { |
| 685 | 688 | const results = await Promise.allSettled( |
| ... | ... | @@ -687,6 +690,9 @@ function DeviceIntegrationControls({ |
| 687 | 690 | if (environment.connection.phase !== "connected" || !environment.serverConfig) { |
| 688 | 691 | throw new Error("Environment disconnected"); |
| 689 | 692 | } |
| 693 | if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) { | |
| 694 | throw new Error("This connection cannot change device settings."); | |
| 695 | } | |
| 690 | 696 | return configure({ |
| 691 | 697 | environmentId: environment.environmentId, |
| 692 | 698 | input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) }, |
| ... | ... | @@ -786,7 +792,14 @@ function DeviceIntegrationControls({ |
| 786 | 792 | <ScopedSwitch |
| 787 | 793 | settingKeys={["enableDeviceSupport"]} |
| 788 | 794 | checked={enabled} |
| 789 | disabled={projectScope || !loaded || !environmentId || busy || pending !== null} | |
| 795 | disabled={ | |
| 796 | !canConfigure || | |
| 797 | projectScope || | |
| 798 | !loaded || | |
| 799 | !environmentId || | |
| 800 | busy || | |
| 801 | pending !== null | |
| 802 | } | |
| 790 | 803 | aria-label="Device hub" |
| 791 | 804 | onCheckedChange={(checked) => |
| 792 | 805 | void update("hub", { |
| ... | ... | @@ -851,6 +864,7 @@ function DeviceIntegrationControls({ |
| 851 | 864 | settingKeys={["enableAgentDeviceAccess"]} |
| 852 | 865 | checked={agentAccessEnabled} |
| 853 | 866 | disabled={ |
| 867 | !canConfigure || | |
| 854 | 868 | connectedEnvironments.length === 0 || |
| 855 | 869 | (!projectScope && (!loaded || !anyHubEnabled || busy)) || |
| 856 | 870 | pending !== null |
apps/web/src/components/settings/KeybindingsSettings.environment.test.tsx created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { visitElements } from "../../test/reactElementTree"; | |
| 5 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; | |
| 6 | ||
| 7 | const state = vi.hoisted(() => ({ | |
| 8 | canOperate: false, | |
| 9 | canWriteSettings: true, | |
| 10 | openEditor: vi.fn(), | |
| 11 | upsert: vi.fn(), | |
| 12 | remove: vi.fn(), | |
| 13 | })); | |
| 14 | ||
| 15 | vi.mock("react", async (importOriginal) => { | |
| 16 | const actual = await importOriginal<typeof import("react")>(); | |
| 17 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 18 | return { | |
| 19 | ...actual, | |
| 20 | useCallback: reactHookHarness.useCallback, | |
| 21 | useEffect: () => undefined, | |
| 22 | useMemo: reactHookHarness.useMemo, | |
| 23 | useRef: reactHookHarness.useRef, | |
| 24 | useState: reactHookHarness.useState, | |
| 25 | }; | |
| 26 | }); | |
| 27 | ||
| 28 | vi.mock("react/compiler-runtime", async () => { | |
| 29 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 30 | return { c: reactHookHarness.useMemoCache }; | |
| 31 | }); | |
| 32 | ||
| 33 | vi.mock("@effect/atom-react", () => ({ | |
| 34 | useAtomValue: (atom: string) => (atom === "config-path" ? "/fixture/keybindings.json" : []), | |
| 35 | })); | |
| 36 | ||
| 37 | vi.mock("@tanstack/react-router", () => ({ useLocation: () => "" })); | |
| 38 | vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null })); | |
| 39 | vi.mock("../../state/server", () => ({ | |
| 40 | primaryServerKeybindingsAtom: "keybindings", | |
| 41 | primaryServerKeybindingsConfigPathAtom: "config-path", | |
| 42 | primaryServerAvailableEditorsAtom: "editors", | |
| 43 | serverEnvironment: { upsertKeybinding: state.upsert, removeKeybinding: state.remove }, | |
| 44 | })); | |
| 45 | ||
| 46 | vi.mock("../../state/environments", () => ({ | |
| 47 | usePrimaryEnvironment: () => ({ environmentId: "primary-settings" }), | |
| 48 | })); | |
| 49 | ||
| 50 | vi.mock("./SettingsScopeContext", () => ({ | |
| 51 | useSettingsScope: () => { | |
| 52 | const environment = { | |
| 53 | environmentId: "primary-settings", | |
| 54 | serverConfig: { | |
| 55 | keybindings: [], | |
| 56 | keybindingsConfigPath: "/fixture/keybindings.json", | |
| 57 | availableEditors: [], | |
| 58 | }, | |
| 59 | }; | |
| 60 | return { environment, connectedEnvironments: [environment] }; | |
| 61 | }, | |
| 62 | })); | |
| 63 | ||
| 64 | vi.mock("../../state/session", () => { | |
| 65 | const hasScope = (environmentId: EnvironmentId | null, scope: string) => | |
| 66 | environmentId === "primary-settings" && | |
| 67 | (scope === "orchestration:operate" | |
| 68 | ? state.canOperate | |
| 69 | : scope === "settings:write" && state.canWriteSettings); | |
| 70 | return { | |
| 71 | useEnvironmentScope: hasScope, | |
| 72 | readEnvironmentScope: hasScope, | |
| 73 | useEnvironmentsWithScope: () => new Set(state.canWriteSettings ? ["primary-settings"] : []), | |
| 74 | }; | |
| 75 | }); | |
| 76 | ||
| 77 | vi.mock("../../state/use-atom-command", () => ({ | |
| 78 | useAtomCommand: (command: unknown) => command, | |
| 79 | })); | |
| 80 | ||
| 81 | vi.mock("../../editorPreferences", () => ({ | |
| 82 | useOpenInPreferredEditor: (environmentId: EnvironmentId) => (path: string) => | |
| 83 | state.openEditor({ environmentId, path }), | |
| 84 | })); | |
| 85 | ||
| 86 | import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; | |
| 87 | ||
| 88 | function renderPanel() { | |
| 89 | hooks.beginRender(); | |
| 90 | return KeybindingsSettingsPanel(); | |
| 91 | } | |
| 92 | ||
| 93 | function openButton(panel: unknown) { | |
| 94 | const button = visitElements( | |
| 95 | panel, | |
| 96 | (element) => element.props["aria-label"] === "Open keybindings.json", | |
| 97 | ); | |
| 98 | if (!button) throw new Error("Missing Open keybindings.json action."); | |
| 99 | return button; | |
| 100 | } | |
| 101 | ||
| 102 | describe("KeybindingsSettings editor permission", () => { | |
| 103 | beforeEach(() => { | |
| 104 | hooks.reset(); | |
| 105 | state.canOperate = false; | |
| 106 | state.canWriteSettings = true; | |
| 107 | state.openEditor.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | |
| 108 | state.upsert.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | |
| 109 | state.remove.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); | |
| 110 | }); | |
| 111 | ||
| 112 | it("rejects editor launches when settings writes are the only write grant", () => { | |
| 113 | const button = openButton(renderPanel()); | |
| 114 | (button.props.onClick as () => void)(); | |
| 115 | ||
| 116 | expect(state.openEditor).not.toHaveBeenCalled(); | |
| 117 | expect(button.props.disabled).toBe(true); | |
| 118 | }); | |
| 119 | ||
| 120 | it("opens the primary environment's file after operate is granted without settings writes", () => { | |
| 121 | state.canWriteSettings = false; | |
| 122 | renderPanel(); | |
| 123 | state.canOperate = true; | |
| 124 | const button = openButton(renderPanel()); | |
| 125 | expect(button.props.disabled).toBe(false); | |
| 126 | (button.props.onClick as () => void)(); | |
| 127 | ||
| 128 | expect(state.openEditor).toHaveBeenCalledWith({ | |
| 129 | environmentId: "primary-settings", | |
| 130 | path: "/fixture/keybindings.json", | |
| 131 | }); | |
| 132 | }); | |
| 133 | ||
| 134 | it("rejects a queued editor launch after operate is revoked", () => { | |
| 135 | state.canOperate = true; | |
| 136 | const open = openButton(renderPanel()).props.onClick as () => void; | |
| 137 | state.canOperate = false; | |
| 138 | open(); | |
| 139 | ||
| 140 | expect(state.openEditor).not.toHaveBeenCalled(); | |
| 141 | expect(openButton(renderPanel()).props.disabled).toBe(true); | |
| 142 | }); | |
| 143 | }); |
apps/web/src/components/settings/KeybindingsSettings.tsx+67-31| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useEnvironmentScope } from "../../state/session"; | |
| 2 | import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { |
| 2 | 4 | ChevronDownIcon, |
| 3 | 5 | CircleXIcon, |
| ... | ... | @@ -22,6 +24,8 @@ import { |
| 22 | 24 | useState, |
| 23 | 25 | } from "react"; |
| 24 | 26 | import { |
| 27 | AuthOrchestrationOperateScope, | |
| 28 | AuthSettingsWriteScope, | |
| 25 | 29 | type KeybindingCommand, |
| 26 | 30 | type KeybindingWhenNode, |
| 27 | 31 | type ServerRemoveKeybindingInput, |
| ... | ... | @@ -1281,6 +1285,14 @@ export function KeybindingsSettingsPanel() { |
| 1281 | 1285 | // fan out to every connected environment in the selection, so one |
| 1282 | 1286 | // shortcut change reaches each machine the user runs T3 Code on. |
| 1283 | 1287 | const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); |
| 1288 | const canOpenKeybindingsFile = useEnvironmentScope( | |
| 1289 | primaryEnvironment?.environmentId ?? null, | |
| 1290 | AuthOrchestrationOperateScope, | |
| 1291 | ); | |
| 1292 | const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope); | |
| 1293 | const canWriteSettings = | |
| 1294 | connectedEnvironments.length > 0 && | |
| 1295 | connectedEnvironments.every((target) => writableIds.has(target.environmentId)); | |
| 1284 | 1296 | const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; |
| 1285 | 1297 | const keybindings = useMemo( |
| 1286 | 1298 | () => mergeWithDefaultKeybindings(serverKeybindings ?? []), |
| ... | ... | @@ -1351,7 +1363,12 @@ export function KeybindingsSettingsPanel() { |
| 1351 | 1363 | }, []); |
| 1352 | 1364 | |
| 1353 | 1365 | const openKeybindingsFile = useCallback(() => { |
| 1354 | if (!keybindingsConfigPath) return; | |
| 1366 | if ( | |
| 1367 | !keybindingsConfigPath || | |
| 1368 | !primaryEnvironment || | |
| 1369 | !readEnvironmentScope(primaryEnvironment.environmentId, AuthOrchestrationOperateScope) | |
| 1370 | ) | |
| 1371 | return; | |
| 1355 | 1372 | void (async () => { |
| 1356 | 1373 | const result = await openInPreferredEditor(keybindingsConfigPath); |
| 1357 | 1374 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { |
| ... | ... | @@ -1365,11 +1382,17 @@ export function KeybindingsSettingsPanel() { |
| 1365 | 1382 | type: "error", |
| 1366 | 1383 | }); |
| 1367 | 1384 | })(); |
| 1368 | }, [keybindingsConfigPath, openInPreferredEditor]); | |
| 1385 | }, [keybindingsConfigPath, openInPreferredEditor, primaryEnvironment]); | |
| 1369 | 1386 | |
| 1370 | 1387 | const saveKeybinding = useCallback( |
| 1371 | 1388 | (input: ServerUpsertKeybindingInput) => { |
| 1372 | if (!primaryEnvironment) return; | |
| 1389 | if ( | |
| 1390 | !primaryEnvironment || | |
| 1391 | !connectedEnvironments.every((target) => | |
| 1392 | readEnvironmentScope(target.environmentId, AuthSettingsWriteScope), | |
| 1393 | ) | |
| 1394 | ) | |
| 1395 | return; | |
| 1373 | 1396 | setSavingCommand(input.command); |
| 1374 | 1397 | const payload: ServerUpsertKeybindingInput = { |
| 1375 | 1398 | command: input.command, |
| ... | ... | @@ -1404,7 +1427,13 @@ export function KeybindingsSettingsPanel() { |
| 1404 | 1427 | |
| 1405 | 1428 | const removeKeybinding = useCallback( |
| 1406 | 1429 | (row: KeybindingRow) => { |
| 1407 | if (!primaryEnvironment) return; | |
| 1430 | if ( | |
| 1431 | !primaryEnvironment || | |
| 1432 | !connectedEnvironments.every((target) => | |
| 1433 | readEnvironmentScope(target.environmentId, AuthSettingsWriteScope), | |
| 1434 | ) | |
| 1435 | ) | |
| 1436 | return; | |
| 1408 | 1437 | setSavingCommand(row.command); |
| 1409 | 1438 | void (async () => { |
| 1410 | 1439 | const results = await Promise.all( |
| ... | ... | @@ -1468,7 +1497,7 @@ export function KeybindingsSettingsPanel() { |
| 1468 | 1497 | <Button |
| 1469 | 1498 | type="button" |
| 1470 | 1499 | variant="outline" |
| 1471 | disabled={isAddingBinding} | |
| 1500 | disabled={isAddingBinding || !canWriteSettings} | |
| 1472 | 1501 | onClick={() => setIsAddingBinding(true)} |
| 1473 | 1502 | > |
| 1474 | 1503 | <PlusIcon aria-hidden className="size-4" /> |
| ... | ... | @@ -1481,7 +1510,7 @@ export function KeybindingsSettingsPanel() { |
| 1481 | 1510 | type="button" |
| 1482 | 1511 | variant="outline" |
| 1483 | 1512 | size="icon" |
| 1484 | disabled={!keybindingsConfigPath} | |
| 1513 | disabled={!keybindingsConfigPath || !canOpenKeybindingsFile} | |
| 1485 | 1514 | onClick={openKeybindingsFile} |
| 1486 | 1515 | aria-label="Open keybindings.json" |
| 1487 | 1516 | > |
| ... | ... | @@ -1494,33 +1523,40 @@ export function KeybindingsSettingsPanel() { |
| 1494 | 1523 | </div> |
| 1495 | 1524 | </SettingsSection> |
| 1496 | 1525 | |
| 1497 | {isAddingBinding ? ( | |
| 1498 | <SettingsGroup> | |
| 1499 | <NewKeybindingSettingsRow | |
| 1500 | commandOptions={commandOptions} | |
| 1501 | allRows={rows} | |
| 1502 | variables={whenVariables} | |
| 1503 | isSaving={savingCommand !== null} | |
| 1504 | onSave={saveKeybinding} | |
| 1505 | onCancel={cancelAdd} | |
| 1506 | /> | |
| 1507 | </SettingsGroup> | |
| 1526 | {!canWriteSettings ? ( | |
| 1527 | <p className="text-xs text-muted-foreground"> | |
| 1528 | This connection can view keybindings but cannot change them. | |
| 1529 | </p> | |
| 1508 | 1530 | ) : null} |
| 1531 | <div inert={!canWriteSettings}> | |
| 1532 | {isAddingBinding ? ( | |
| 1533 | <SettingsGroup> | |
| 1534 | <NewKeybindingSettingsRow | |
| 1535 | commandOptions={commandOptions} | |
| 1536 | allRows={rows} | |
| 1537 | variables={whenVariables} | |
| 1538 | isSaving={savingCommand !== null} | |
| 1539 | onSave={saveKeybinding} | |
| 1540 | onCancel={cancelAdd} | |
| 1541 | /> | |
| 1542 | </SettingsGroup> | |
| 1543 | ) : null} | |
| 1509 | 1544 | |
| 1510 | {groups.length > 0 ? ( | |
| 1511 | <KeybindingsGroups | |
| 1512 | groups={groups} | |
| 1513 | anchorIds={anchorIds} | |
| 1514 | savingCommand={savingCommand} | |
| 1515 | {...rowActions} | |
| 1516 | /> | |
| 1517 | ) : ( | |
| 1518 | <SettingsGroup> | |
| 1519 | <div className="px-4 py-12 text-center text-sm text-muted-foreground"> | |
| 1520 | No keybindings match your search. | |
| 1521 | </div> | |
| 1522 | </SettingsGroup> | |
| 1523 | )} | |
| 1545 | {groups.length > 0 ? ( | |
| 1546 | <KeybindingsGroups | |
| 1547 | groups={groups} | |
| 1548 | anchorIds={anchorIds} | |
| 1549 | savingCommand={savingCommand} | |
| 1550 | {...rowActions} | |
| 1551 | /> | |
| 1552 | ) : ( | |
| 1553 | <SettingsGroup> | |
| 1554 | <div className="px-4 py-12 text-center text-sm text-muted-foreground"> | |
| 1555 | No keybindings match your search. | |
| 1556 | </div> | |
| 1557 | </SettingsGroup> | |
| 1558 | )} | |
| 1559 | </div> | |
| 1524 | 1560 | </SettingsPageContainer> |
| 1525 | 1561 | ); |
| 1526 | 1562 | } |
apps/web/src/components/settings/ProjectActionsSettings.tsx+19-12| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; | |
| 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 1 | 3 | import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts"; |
| 2 | 4 | import { |
| 3 | 5 | isAtomCommandInterrupted, |
| ... | ... | @@ -52,6 +54,7 @@ export function ProjectActionsSettings() { |
| 52 | 54 | (candidate) => |
| 53 | 55 | JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts), |
| 54 | 56 | ); |
| 57 | const canWriteSettings = useScopedSettingsWriteAllowed(); | |
| 55 | 58 | const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| 56 | 59 | const memberById = new Map( |
| 57 | 60 | isProjectScope ? scope.members.map((member) => [member.id, member]) : [], |
| ... | ... | @@ -148,7 +151,7 @@ export function ProjectActionsSettings() { |
| 148 | 151 | id="import-scripts" |
| 149 | 152 | size="xs" |
| 150 | 153 | variant="ghost" |
| 151 | disabled={saving} | |
| 154 | disabled={saving || !canWriteSettings} | |
| 152 | 155 | type="button" |
| 153 | 156 | /> |
| 154 | 157 | } |
| ... | ... | @@ -184,7 +187,7 @@ export function ProjectActionsSettings() { |
| 184 | 187 | <Button |
| 185 | 188 | size="xs" |
| 186 | 189 | variant="outline" |
| 187 | disabled={saving || targets.length === 0} | |
| 190 | disabled={saving || !canWriteSettings || targets.length === 0} | |
| 188 | 191 | onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })} |
| 189 | 192 | > |
| 190 | 193 | <PlusIcon className="size-3.5" /> |
| ... | ... | @@ -202,7 +205,7 @@ export function ProjectActionsSettings() { |
| 202 | 205 | <ProjectActionsList |
| 203 | 206 | scripts={scripts} |
| 204 | 207 | keybindings={keybindings} |
| 205 | disabled={saving} | |
| 208 | disabled={saving || !canWriteSettings} | |
| 206 | 209 | onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))} |
| 207 | 210 | /> |
| 208 | 211 | )} |
| ... | ... | @@ -213,15 +216,19 @@ export function ProjectActionsSettings() { |
| 213 | 216 | className="text-warning" |
| 214 | 217 | /> |
| 215 | 218 | ) : null} |
| 216 | <ProjectScriptEditorDialog | |
| 217 | request={request} | |
| 218 | scripts={scripts} | |
| 219 | onSubmit={submit} | |
| 220 | onDelete={(id) => | |
| 221 | void persist((current) => current.filter((script) => script.id !== id), id, null) | |
| 222 | } | |
| 223 | onClose={() => setRequest(null)} | |
| 224 | /> | |
| 219 | {target && ( | |
| 220 | <ProjectScriptEditorDialog | |
| 221 | environmentId={target.environmentId} | |
| 222 | editScope={AuthSettingsWriteScope} | |
| 223 | request={request} | |
| 224 | scripts={scripts} | |
| 225 | onSubmit={submit} | |
| 226 | onDelete={(id) => | |
| 227 | void persist((current) => current.filter((script) => script.id !== id), id, null) | |
| 228 | } | |
| 229 | onClose={() => setRequest(null)} | |
| 230 | /> | |
| 231 | )} | |
| 225 | 232 | </SettingsSection> |
| 226 | 233 | ); |
| 227 | 234 | } |
apps/web/src/components/settings/ProjectSettingsPanel.test.tsx created+476| ... | ... | @@ -0,0 +1,476 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import type { | |
| 3 | DeleteProjectInput, | |
| 4 | UpdateProjectInput, | |
| 5 | } from "@t3tools/client-runtime/state/projects"; | |
| 6 | import { | |
| 7 | AuthOrchestrationOperateScope, | |
| 8 | AuthSettingsWriteScope, | |
| 9 | DEFAULT_SERVER_SETTINGS, | |
| 10 | EnvironmentId, | |
| 11 | ProjectId, | |
| 12 | ProviderDriverKind, | |
| 13 | ProviderInstanceId, | |
| 14 | type AuthEnvironmentScope, | |
| 15 | type AuthSessionState, | |
| 16 | type ResolvedKeybindingsConfig, | |
| 17 | type ServerProvider, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | |
| 20 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 21 | import * as Cause from "effect/Cause"; | |
| 22 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 23 | import { cloneElement, type ReactElement, type ReactNode } from "react"; | |
| 24 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | |
| 25 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 26 | ||
| 27 | import type { Project } from "../../types"; | |
| 28 | ||
| 29 | type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>; | |
| 30 | const state = vi.hoisted(() => ({ | |
| 31 | registry: null as AtomRegistry.AtomRegistry | null, | |
| 32 | sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(), | |
| 33 | providers: null as Atom.Atom<ReadonlyArray<ServerProvider>> | null, | |
| 34 | config: null as Atom.Atom<{ keybindings: ResolvedKeybindingsConfig }> | null, | |
| 35 | projects: [] as Project[], | |
| 36 | update: | |
| 37 | vi.fn< | |
| 38 | (request: { | |
| 39 | environmentId: EnvironmentId; | |
| 40 | input: UpdateProjectInput; | |
| 41 | }) => Promise<AsyncResult.Success<void>> | |
| 42 | >(), | |
| 43 | delete: | |
| 44 | vi.fn< | |
| 45 | (request: { | |
| 46 | environmentId: EnvironmentId; | |
| 47 | input: DeleteProjectInput; | |
| 48 | }) => Promise<AsyncResult.Success<void>> | |
| 49 | >(), | |
| 50 | confirm: vi.fn<() => Promise<boolean>>(), | |
| 51 | updateSettings: vi.fn(), | |
| 52 | upsertKeybinding: vi.fn(), | |
| 53 | removeKeybinding: vi.fn(), | |
| 54 | updateClientSettings: vi.fn(), | |
| 55 | releaseUploads: vi.fn(), | |
| 56 | clearDraft: vi.fn(), | |
| 57 | copy: vi.fn(), | |
| 58 | navigate: vi.fn(), | |
| 59 | })); | |
| 60 | ||
| 61 | vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined })); | |
| 62 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | |
| 63 | createEnvironmentSessionAtoms: () => ({ | |
| 64 | sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, | |
| 65 | }), | |
| 66 | })); | |
| 67 | vi.mock("~/rpc/atomRegistry", () => ({ | |
| 68 | get appAtomRegistry() { | |
| 69 | return state.registry; | |
| 70 | }, | |
| 71 | })); | |
| 72 | vi.mock("~/state/environments", () => ({ | |
| 73 | useEnvironments: () => ({ environments }), | |
| 74 | usePrimaryEnvironmentId: () => primaryId, | |
| 75 | })); | |
| 76 | vi.mock("~/state/entities", () => ({ | |
| 77 | useProjects: () => state.projects, | |
| 78 | useThreadShells: () => [], | |
| 79 | })); | |
| 80 | vi.mock("~/state/projects", () => ({ | |
| 81 | projectEnvironment: { update: "update", delete: "delete" }, | |
| 82 | })); | |
| 83 | vi.mock("~/state/server", () => ({ | |
| 84 | EMPTY_SERVER_PROVIDERS: [], | |
| 85 | serverEnvironment: { | |
| 86 | providersValueAtom: () => state.providers!, | |
| 87 | configValueAtom: () => state.config!, | |
| 88 | updateSettings: "updateSettings", | |
| 89 | upsertKeybinding: "upsertKeybinding", | |
| 90 | removeKeybinding: "removeKeybinding", | |
| 91 | }, | |
| 92 | })); | |
| 93 | vi.mock("~/state/use-atom-command", () => ({ | |
| 94 | useAtomCommand: ( | |
| 95 | command: "update" | "delete" | "updateSettings" | "upsertKeybinding" | "removeKeybinding", | |
| 96 | ) => state[command], | |
| 97 | })); | |
| 98 | vi.mock("~/hooks/useSettings", () => ({ | |
| 99 | useClientSettings: () => DEFAULT_CLIENT_SETTINGS, | |
| 100 | useUpdateClientSettings: () => state.updateClientSettings, | |
| 101 | usePrimarySettings: () => DEFAULT_SERVER_SETTINGS, | |
| 102 | useEnvironmentSettings: () => DEFAULT_SERVER_SETTINGS, | |
| 103 | })); | |
| 104 | vi.mock("~/hooks/useT3ProjectFileScripts", () => ({ | |
| 105 | useT3ProjectFileState: () => ({ | |
| 106 | status: "valid", | |
| 107 | file: null, | |
| 108 | scripts: [{ name: "Imported", command: "vp test" }], | |
| 109 | }), | |
| 110 | })); | |
| 111 | vi.mock("~/hooks/useCopyToClipboard", () => ({ | |
| 112 | useCopyToClipboard: () => ({ copyToClipboard: state.copy }), | |
| 113 | })); | |
| 114 | vi.mock("~/localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: state.confirm } }) })); | |
| 115 | vi.mock("~/composerDraftStore", () => ({ | |
| 116 | useComposerDraftStore: { | |
| 117 | getState: () => ({ | |
| 118 | getDraftThreadByProjectRef: () => null, | |
| 119 | clearProjectDraftThreadId: state.clearDraft, | |
| 120 | }), | |
| 121 | }, | |
| 122 | })); | |
| 123 | vi.mock("~/lib/composerDraftUploads", () => ({ releaseProjectDraftUploads: state.releaseUploads })); | |
| 124 | vi.mock("~/env", () => ({ isElectron: true })); | |
| 125 | vi.mock("@tanstack/react-router", () => ({ | |
| 126 | useNavigate: () => state.navigate, | |
| 127 | useLocation: () => "/settings/projects", | |
| 128 | })); | |
| 129 | vi.mock("../ui/toast", () => ({ | |
| 130 | toastManager: { add: vi.fn() }, | |
| 131 | stackedThreadToast: (value: unknown) => value, | |
| 132 | })); | |
| 133 | vi.mock("../ui/button", () => ({ Button: "button" })); | |
| 134 | vi.mock("../ui/input", () => ({ Input: "input" })); | |
| 135 | vi.mock("../ui/switch", () => ({ Switch: "input" })); | |
| 136 | vi.mock("../ui/select", () => ({ | |
| 137 | Select: "select", | |
| 138 | SelectItem: "option", | |
| 139 | SelectPopup: "div", | |
| 140 | SelectTrigger: "button", | |
| 141 | SelectValue: "span", | |
| 142 | })); | |
| 143 | vi.mock("../ui/menu", () => ({ | |
| 144 | Menu: "div", | |
| 145 | MenuGroup: "div", | |
| 146 | MenuGroupLabel: "div", | |
| 147 | MenuItem: "button", | |
| 148 | MenuPopup: "div", | |
| 149 | MenuSeparator: "hr", | |
| 150 | MenuTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => | |
| 151 | cloneElement(render, {}, children), | |
| 152 | })); | |
| 153 | vi.mock("../ui/tooltip", () => ({ | |
| 154 | Tooltip: "div", | |
| 155 | TooltipPopup: "span", | |
| 156 | TooltipTrigger: ({ render }: { render: ReactNode }) => render, | |
| 157 | })); | |
| 158 | vi.mock("./settingsLayout", () => ({ | |
| 159 | SETTINGS_PICKER_TRIGGER_CLASSNAME: "", | |
| 160 | SettingsPageContainer: "div", | |
| 161 | SettingsSection: ({ | |
| 162 | children, | |
| 163 | headerAction, | |
| 164 | }: { | |
| 165 | children: ReactNode; | |
| 166 | headerAction?: ReactNode; | |
| 167 | }) => ( | |
| 168 | <section> | |
| 169 | {headerAction} | |
| 170 | {children} | |
| 171 | </section> | |
| 172 | ), | |
| 173 | SettingsRow: ({ control, resetAction }: { control?: ReactNode; resetAction?: ReactNode }) => ( | |
| 174 | <div> | |
| 175 | {resetAction} | |
| 176 | {control} | |
| 177 | </div> | |
| 178 | ), | |
| 179 | SettingResetButton: ({ | |
| 180 | label, | |
| 181 | ...props | |
| 182 | }: { | |
| 183 | label: string; | |
| 184 | disabled?: boolean; | |
| 185 | onClick: () => void; | |
| 186 | }) => <button {...props} aria-label={`Reset ${label} to default`} />, | |
| 187 | })); | |
| 188 | vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null })); | |
| 189 | vi.mock("./ProjectActionsSettings", () => ({ ProjectActionsSettings: () => null })); | |
| 190 | vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null })); | |
| 191 | vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: "model-picker" })); | |
| 192 | vi.mock("../chat/TraitsPicker", () => ({ TraitsPicker: "traits-picker" })); | |
| 193 | vi.mock("./ProjectFaviconPickerDialog", () => ({ | |
| 194 | canPickExternalProjectFavicon: () => false, | |
| 195 | ProjectFaviconPickerDialog: "favicon-picker", | |
| 196 | })); | |
| 197 | vi.mock("./ProjectIconPickerDialog", () => ({ ProjectIconPickerDialog: "icon-picker" })); | |
| 198 | vi.mock("../projectScriptEditor", async (importOriginal) => ({ | |
| 199 | ...(await importOriginal<typeof import("../projectScriptEditor")>()), | |
| 200 | ProjectScriptEditorDialog: "script-editor", | |
| 201 | })); | |
| 202 | ||
| 203 | import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; | |
| 204 | import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog"; | |
| 205 | import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; | |
| 206 | ||
| 207 | const primaryId = EnvironmentId.make("primary"); | |
| 208 | const remoteId = EnvironmentId.make("remote"); | |
| 209 | const instanceId = ProviderInstanceId.make("codex"); | |
| 210 | const environments = [primaryId, remoteId].map((environmentId) => ({ | |
| 211 | environmentId, | |
| 212 | label: environmentId, | |
| 213 | connection: { phase: "connected" }, | |
| 214 | serverConfig: { keybindings: DEFAULT_RESOLVED_KEYBINDINGS }, | |
| 215 | })); | |
| 216 | const session = (scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState => ({ | |
| 217 | authenticated: true, | |
| 218 | scopes, | |
| 219 | auth: { | |
| 220 | policy: "remote-reachable", | |
| 221 | bootstrapMethods: ["one-time-token"], | |
| 222 | sessionMethods: ["bearer-access-token"], | |
| 223 | sessionCookieName: "t3_session", | |
| 224 | }, | |
| 225 | }); | |
| 226 | const writable = () => AsyncResult.success(session([AuthOrchestrationOperateScope])); | |
| 227 | ||
| 228 | function deferred<T>() { | |
| 229 | let resolve!: (value: T) => void; | |
| 230 | const promise = new Promise<T>((complete) => { | |
| 231 | resolve = complete; | |
| 232 | }); | |
| 233 | return { promise, resolve }; | |
| 234 | } | |
| 235 | ||
| 236 | let renderer: ReactTestRenderer | undefined; | |
| 237 | ||
| 238 | beforeEach(() => { | |
| 239 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 240 | vi.clearAllMocks(); | |
| 241 | state.registry = AtomRegistry.make(); | |
| 242 | state.sessions.clear(); | |
| 243 | for (const id of [primaryId, remoteId]) | |
| 244 | state.sessions.set(id, Atom.make<SessionResult>(writable()).pipe(Atom.keepAlive)); | |
| 245 | state.projects = [primaryId, remoteId].map((environmentId) => ({ | |
| 246 | id: ProjectId.make(`project-${environmentId}`), | |
| 247 | environmentId, | |
| 248 | title: "Shared repo", | |
| 249 | workspaceRoot: `/work/${environmentId}`, | |
| 250 | repositoryIdentity: { | |
| 251 | canonicalKey: "github.com/example/shared-repo", | |
| 252 | locator: { | |
| 253 | source: "git-remote", | |
| 254 | remoteName: "origin", | |
| 255 | remoteUrl: "https://github.com/example/shared-repo.git", | |
| 256 | }, | |
| 257 | }, | |
| 258 | defaultModelSelection: { instanceId, model: "gpt-5-codex" }, | |
| 259 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 260 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 261 | scripts: [], | |
| 262 | })); | |
| 263 | state.providers = Atom.make<ReadonlyArray<ServerProvider>>([ | |
| 264 | { | |
| 265 | instanceId, | |
| 266 | driver: ProviderDriverKind.make("codex"), | |
| 267 | enabled: true, | |
| 268 | installed: true, | |
| 269 | version: null, | |
| 270 | status: "ready", | |
| 271 | auth: { status: "authenticated" }, | |
| 272 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 273 | models: [], | |
| 274 | slashCommands: [], | |
| 275 | skills: [], | |
| 276 | }, | |
| 277 | ]); | |
| 278 | state.config = Atom.make({ keybindings: DEFAULT_RESOLVED_KEYBINDINGS }); | |
| 279 | state.update.mockImplementation(async ({ environmentId, input }) => { | |
| 280 | const { projectId, ...updates } = input; | |
| 281 | const patch = Object.fromEntries( | |
| 282 | Object.entries(updates).filter(([, value]) => value !== undefined), | |
| 283 | ) as Partial<Project>; | |
| 284 | state.projects = state.projects.map((project) => | |
| 285 | project.environmentId === environmentId && project.id === projectId | |
| 286 | ? { ...project, ...patch } | |
| 287 | : project, | |
| 288 | ); | |
| 289 | return AsyncResult.success(undefined); | |
| 290 | }); | |
| 291 | state.delete.mockResolvedValue(AsyncResult.success(undefined)); | |
| 292 | state.updateSettings.mockResolvedValue(AsyncResult.success(undefined)); | |
| 293 | state.confirm.mockResolvedValue(true); | |
| 294 | }); | |
| 295 | ||
| 296 | afterEach(async () => { | |
| 297 | await act(() => renderer?.unmount()); | |
| 298 | renderer = undefined; | |
| 299 | state.registry?.dispose(); | |
| 300 | vi.unstubAllGlobals(); | |
| 301 | }); | |
| 302 | ||
| 303 | function group() { | |
| 304 | return buildSidebarProjectSnapshots({ | |
| 305 | projects: state.projects, | |
| 306 | settings: DEFAULT_CLIENT_SETTINGS, | |
| 307 | primaryEnvironmentId: primaryId, | |
| 308 | resolveEnvironmentLabel: (id) => id, | |
| 309 | })[0]!; | |
| 310 | } | |
| 311 | ||
| 312 | async function mountPanel() { | |
| 313 | await act(() => { | |
| 314 | renderer = create( | |
| 315 | <RegistryContext.Provider value={state.registry!}> | |
| 316 | <ProjectSettingsPanel projectKey={group().projectKey} /> | |
| 317 | </RegistryContext.Provider>, | |
| 318 | ); | |
| 319 | }); | |
| 320 | return renderer!.root; | |
| 321 | } | |
| 322 | ||
| 323 | function button(label: string) { | |
| 324 | return renderer!.root.findAllByType("button").find((node) => node.children.includes(label))!; | |
| 325 | } | |
| 326 | ||
| 327 | async function grant(id: EnvironmentId, result: SessionResult) { | |
| 328 | await act(() => state.registry!.set(state.sessions.get(id)!, result)); | |
| 329 | } | |
| 330 | ||
| 331 | describe("project settings permissions", () => { | |
| 332 | it("preflights every member before any shared field update", async () => { | |
| 333 | await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope]))); | |
| 334 | const root = await mountPanel(); | |
| 335 | const name = root.findByProps({ "aria-label": "Project name" }); | |
| 336 | ||
| 337 | await act(async () => { | |
| 338 | name.props.onChange(); | |
| 339 | name.props.onBlur({ currentTarget: { value: "Renamed" } }); | |
| 340 | root.findByType(ProjectFaviconPickerDialog).props.onSelect("/work/icon.png"); | |
| 341 | }); | |
| 342 | ||
| 343 | expect(state.update).not.toHaveBeenCalled(); | |
| 344 | expect(state.projects.every((project) => project.title === "Shared repo")).toBe(true); | |
| 345 | expect(name.props.disabled).toBe(true); | |
| 346 | expect(button("Remove all entries").props.disabled).toBe(true); | |
| 347 | }); | |
| 348 | ||
| 349 | it("updates every member when all have project access without settings access", async () => { | |
| 350 | const root = await mountPanel(); | |
| 351 | await act(async () => { | |
| 352 | const name = root.findByProps({ "aria-label": "Project name" }); | |
| 353 | name.props.onChange(); | |
| 354 | name.props.onBlur({ currentTarget: { value: "Renamed" } }); | |
| 355 | }); | |
| 356 | expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([ | |
| 357 | primaryId, | |
| 358 | remoteId, | |
| 359 | ]); | |
| 360 | expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]); | |
| 361 | }); | |
| 362 | ||
| 363 | it("stops before the next member if its grant is revoked during an earlier update", async () => { | |
| 364 | const firstUpdate = deferred<AsyncResult.Success<void>>(); | |
| 365 | state.update.mockReturnValueOnce(firstUpdate.promise); | |
| 366 | const root = await mountPanel(); | |
| 367 | await act(async () => { | |
| 368 | const name = root.findByProps({ "aria-label": "Project name" }); | |
| 369 | name.props.onChange(); | |
| 370 | name.props.onBlur({ currentTarget: { value: "Renamed" } }); | |
| 371 | }); | |
| 372 | expect(state.update).toHaveBeenCalledOnce(); | |
| 373 | ||
| 374 | await grant(remoteId, AsyncResult.success(session([]))); | |
| 375 | await act(async () => { | |
| 376 | firstUpdate.resolve(AsyncResult.success(undefined)); | |
| 377 | await firstUpdate.promise; | |
| 378 | }); | |
| 379 | expect(state.update).toHaveBeenCalledOnce(); | |
| 380 | expect(state.update.mock.calls[0]![0].environmentId).toBe(primaryId); | |
| 381 | }); | |
| 382 | ||
| 383 | it("disables removal for a checkout whose project grant is missing", async () => { | |
| 384 | await grant(remoteId, AsyncResult.success(session([]))); | |
| 385 | const root = await mountPanel(); | |
| 386 | const denied = root.findByProps({ "aria-label": "Remove checkout /work/remote" }); | |
| 387 | expect(denied.props.disabled).toBe(true); | |
| 388 | await act(async () => denied.props.onClick()); | |
| 389 | expect(state.confirm).not.toHaveBeenCalled(); | |
| 390 | expect(state.delete).not.toHaveBeenCalled(); | |
| 391 | expect(root.findByProps({ "aria-label": "Remove checkout /work/primary" }).props.disabled).toBe( | |
| 392 | false, | |
| 393 | ); | |
| 394 | }); | |
| 395 | ||
| 396 | it("allows removing a writable checkout while denying removal of the mixed group", async () => { | |
| 397 | await grant(remoteId, AsyncResult.success(session([]))); | |
| 398 | await mountPanel(); | |
| 399 | await act(async () => button("Remove all entries").props.onClick()); | |
| 400 | expect(state.confirm).not.toHaveBeenCalled(); | |
| 401 | expect(state.delete).not.toHaveBeenCalled(); | |
| 402 | await act(async () => | |
| 403 | renderer!.root.findByProps({ "aria-label": "Remove checkout /work/primary" }).props.onClick(), | |
| 404 | ); | |
| 405 | expect(state.delete).toHaveBeenCalledExactlyOnceWith({ | |
| 406 | environmentId: primaryId, | |
| 407 | input: { projectId: state.projects[0]!.id, force: true }, | |
| 408 | }); | |
| 409 | expect(state.releaseUploads).toHaveBeenCalledOnce(); | |
| 410 | expect(state.clearDraft).toHaveBeenCalledOnce(); | |
| 411 | }); | |
| 412 | ||
| 413 | it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { | |
| 414 | const confirmed = deferred<boolean>(); | |
| 415 | state.confirm.mockReturnValueOnce(confirmed.promise); | |
| 416 | await mountPanel(); | |
| 417 | await act(async () => button("Remove all entries").props.onClick()); | |
| 418 | expect(state.confirm).toHaveBeenCalledOnce(); | |
| 419 | await grant(remoteId, AsyncResult.success(session([]))); | |
| 420 | await act(async () => { | |
| 421 | confirmed.resolve(true); | |
| 422 | await confirmed.promise; | |
| 423 | }); | |
| 424 | expect(state.delete).not.toHaveBeenCalled(); | |
| 425 | expect(state.releaseUploads).not.toHaveBeenCalled(); | |
| 426 | expect(state.clearDraft).not.toHaveBeenCalled(); | |
| 427 | }); | |
| 428 | ||
| 429 | it("reacts to another member's revoked and restored grant and guards a stale handler", async () => { | |
| 430 | await grant( | |
| 431 | primaryId, | |
| 432 | AsyncResult.success(session([AuthOrchestrationOperateScope, AuthSettingsWriteScope])), | |
| 433 | ); | |
| 434 | const root = await mountPanel(); | |
| 435 | const name = () => root.findByProps({ "aria-label": "Project name" }); | |
| 436 | const staleSave = name().props.onBlur; | |
| 437 | await act(async () => { | |
| 438 | state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); | |
| 439 | name().props.onChange(); | |
| 440 | staleSave({ currentTarget: { value: "Renamed" } }); | |
| 441 | }); | |
| 442 | expect(state.update).not.toHaveBeenCalled(); | |
| 443 | expect(name().props.disabled).toBe(true); | |
| 444 | ||
| 445 | await grant(remoteId, writable()); | |
| 446 | expect(name().props.disabled).toBe(false); | |
| 447 | await act(async () => { | |
| 448 | name().props.onChange(); | |
| 449 | name().props.onBlur({ currentTarget: { value: "Renamed" } }); | |
| 450 | }); | |
| 451 | expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]); | |
| 452 | }); | |
| 453 | ||
| 454 | it.each([ | |
| 455 | ["loading", () => AsyncResult.initial<AuthSessionState, Error>(), false], | |
| 456 | [ | |
| 457 | "failed", | |
| 458 | () => AsyncResult.failure<AuthSessionState, Error>(Cause.fail(new Error("Disconnected"))), | |
| 459 | false, | |
| 460 | ], | |
| 461 | ["refreshing", () => AsyncResult.waiting(writable()), true], | |
| 462 | ] as const)( | |
| 463 | "uses the current grant while a member's session is %s", | |
| 464 | async (_state, result, allowed) => { | |
| 465 | await grant(remoteId, result()); | |
| 466 | const root = await mountPanel(); | |
| 467 | const name = root.findByProps({ "aria-label": "Project name" }); | |
| 468 | expect(name.props.disabled).toBe(!allowed); | |
| 469 | await act(async () => { | |
| 470 | name.props.onChange(); | |
| 471 | name.props.onBlur({ currentTarget: { value: "Renamed" } }); | |
| 472 | }); | |
| 473 | expect(state.update).toHaveBeenCalledTimes(allowed ? 2 : 0); | |
| 474 | }, | |
| 475 | ); | |
| 476 | }); |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+59-11| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import { useComposerMenuState } from "../chat/useComposerMenuState"; | |
| 2 | import { useOrchestrationCommand } from "../../state/use-orchestration-command"; | |
| 3 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 4 | import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session"; | |
| 1 | 5 | import { |
| 2 | 6 | isAtomCommandInterrupted, |
| 3 | 7 | mapAtomCommandResult, |
| ... | ... | @@ -23,7 +27,6 @@ import { |
| 23 | 27 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; |
| 24 | 28 | import { useThreadShells } from "../../state/entities"; |
| 25 | 29 | import { projectEnvironment } from "../../state/projects"; |
| 26 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 27 | 30 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 28 | 31 | import { Alert, AlertDescription } from "../ui/alert"; |
| 29 | 32 | import { Button } from "../ui/button"; |
| ... | ... | @@ -171,13 +174,21 @@ function ProjectDetail({ |
| 171 | 174 | () => new Map(environments.map((environment) => [environment.environmentId, environment])), |
| 172 | 175 | [environments], |
| 173 | 176 | ); |
| 177 | const editableIds = useEnvironmentsWithScope(group.memberProjects, AuthOrchestrationOperateScope); | |
| 178 | const canEditGroup = group.memberProjects.every((member) => | |
| 179 | editableIds.has(member.environmentId), | |
| 180 | ); | |
| 174 | 181 | const representative = |
| 175 | 182 | group.memberProjects.find( |
| 176 | 183 | (member) => environmentById.get(member.environmentId)?.serverConfig != null, |
| 177 | 184 | ) ?? group.memberProjects[0]!; |
| 178 | 185 | const threads = useThreadShells(); |
| 179 | const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); | |
| 180 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); | |
| 186 | const updateProject = useOrchestrationCommand(projectEnvironment.update, { | |
| 187 | reportFailure: false, | |
| 188 | }); | |
| 189 | const deleteProject = useOrchestrationCommand(projectEnvironment.delete, { | |
| 190 | reportFailure: false, | |
| 191 | }); | |
| 181 | 192 | const projectNameEditedRef = useRef(false); |
| 182 | 193 | |
| 183 | 194 | const faviconPath = representative.faviconPath ?? null; |
| ... | ... | @@ -204,6 +215,25 @@ function ProjectDetail({ |
| 204 | 215 | ); |
| 205 | 216 | }, []); |
| 206 | 217 | |
| 218 | const checkProjectAccess = useCallback( | |
| 219 | (members: ReadonlyArray<SidebarProjectGroupMember>, failureTitle: string) => { | |
| 220 | const denied = members.find( | |
| 221 | (member) => !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope), | |
| 222 | ); | |
| 223 | if (!denied) return null; | |
| 224 | const result = AsyncResult.failure<void, Error>( | |
| 225 | Cause.fail( | |
| 226 | new Error( | |
| 227 | `This connection cannot change projects in ${denied.environmentLabel ?? "this environment"}.`, | |
| 228 | ), | |
| 229 | ), | |
| 230 | ); | |
| 231 | reportFailure(failureTitle, result); | |
| 232 | return result; | |
| 233 | }, | |
| 234 | [reportFailure], | |
| 235 | ); | |
| 236 | ||
| 207 | 237 | // Group-shared fields live on each physical project record, so a |
| 208 | 238 | // group-level edit fans out to every member. |
| 209 | 239 | const updateAllMembers = useCallback( |
| ... | ... | @@ -215,6 +245,8 @@ function ProjectDetail({ |
| 215 | 245 | }>, |
| 216 | 246 | failureTitle: string, |
| 217 | 247 | ): Promise<AtomCommandResult<void, unknown>> => { |
| 248 | const denied = checkProjectAccess(group.memberProjects, failureTitle); | |
| 249 | if (denied) return denied; | |
| 218 | 250 | const unavailable = group.memberProjects.find((member) => { |
| 219 | 251 | const environment = environmentById.get(member.environmentId); |
| 220 | 252 | return environment?.connection.phase !== "connected" || !environment.serverConfig; |
| ... | ... | @@ -228,6 +260,8 @@ function ProjectDetail({ |
| 228 | 260 | return result; |
| 229 | 261 | } |
| 230 | 262 | for (const member of group.memberProjects) { |
| 263 | const revoked = checkProjectAccess([member], failureTitle); | |
| 264 | if (revoked) return revoked; | |
| 231 | 265 | const result = mapAtomCommandResult( |
| 232 | 266 | await updateProject({ |
| 233 | 267 | environmentId: member.environmentId, |
| ... | ... | @@ -249,7 +283,7 @@ function ProjectDetail({ |
| 249 | 283 | } |
| 250 | 284 | return AsyncResult.success(undefined); |
| 251 | 285 | }, |
| 252 | [environmentById, group.memberProjects, reportFailure, updateProject], | |
| 286 | [checkProjectAccess, environmentById, group.memberProjects, reportFailure, updateProject], | |
| 253 | 287 | ); |
| 254 | 288 | |
| 255 | 289 | const renameGroup = useCallback( |
| ... | ... | @@ -274,8 +308,8 @@ function ProjectDetail({ |
| 274 | 308 | ); |
| 275 | 309 | |
| 276 | 310 | // ----- project icon ----- |
| 277 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); | |
| 278 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 311 | const [faviconPickerOpen, setFaviconPickerOpen] = useComposerMenuState(!canEditGroup); | |
| 312 | const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditGroup); | |
| 279 | 313 | const [isSavingFavicon, setIsSavingFavicon] = useState(false); |
| 280 | 314 | const savingFaviconRef = useRef(false); |
| 281 | 315 | const setProjectIcon = useCallback( |
| ... | ... | @@ -297,6 +331,7 @@ function ProjectDetail({ |
| 297 | 331 | |
| 298 | 332 | const removeMembers = useCallback( |
| 299 | 333 | async (members: ReadonlyArray<SidebarProjectGroupMember>) => { |
| 334 | if (checkProjectAccess(members, "Failed to remove project")) return; | |
| 300 | 335 | const api = readLocalApi(); |
| 301 | 336 | if (!api) return; |
| 302 | 337 | |
| ... | ... | @@ -336,9 +371,11 @@ function ProjectDetail({ |
| 336 | 371 | ), |
| 337 | 372 | ); |
| 338 | 373 | if (confirmed._tag === "Failure" || !confirmed.value) return; |
| 374 | if (checkProjectAccess(members, "Failed to remove project")) return; | |
| 339 | 375 | |
| 340 | 376 | const draftStore = useComposerDraftStore.getState(); |
| 341 | 377 | for (const member of members) { |
| 378 | if (checkProjectAccess([member], "Failed to remove project")) return; | |
| 342 | 379 | const memberThreads = projectThreads.filter( |
| 343 | 380 | (thread) => |
| 344 | 381 | thread.environmentId === member.environmentId && thread.projectId === member.id, |
| ... | ... | @@ -374,6 +411,7 @@ function ProjectDetail({ |
| 374 | 411 | } |
| 375 | 412 | }, |
| 376 | 413 | [ |
| 414 | checkProjectAccess, | |
| 377 | 415 | deleteProject, |
| 378 | 416 | group.displayName, |
| 379 | 417 | group.memberProjects.length, |
| ... | ... | @@ -395,6 +433,7 @@ function ProjectDetail({ |
| 395 | 433 | <Button |
| 396 | 434 | size="sm" |
| 397 | 435 | variant="outline" |
| 436 | disabled={!editableIds.has(member.environmentId)} | |
| 398 | 437 | onClick={() => void removeMembers([member])} |
| 399 | 438 | aria-label={`Remove checkout ${member.workspaceRoot}`} |
| 400 | 439 | > |
| ... | ... | @@ -416,6 +455,13 @@ function ProjectDetail({ |
| 416 | 455 | </AlertDescription> |
| 417 | 456 | </Alert> |
| 418 | 457 | <SettingsSection id="project-overview" title="Project" hideTitle> |
| 458 | {!canEditGroup ? ( | |
| 459 | <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4"> | |
| 460 | {group.memberProjects.length > 1 | |
| 461 | ? "Shared settings require permission to change every checkout in this group." | |
| 462 | : "This connection cannot change this project."} | |
| 463 | </p> | |
| 464 | ) : null} | |
| 419 | 465 | <SettingsRow |
| 420 | 466 | title="Name" |
| 421 | 467 | description="The shared name for this project group in the sidebar and thread lists." |
| ... | ... | @@ -425,6 +471,7 @@ function ProjectDetail({ |
| 425 | 471 | size="sm" |
| 426 | 472 | className="w-full sm:w-64" |
| 427 | 473 | aria-label="Project name" |
| 474 | disabled={!canEditGroup} | |
| 428 | 475 | defaultValue={group.displayName} |
| 429 | 476 | onChange={() => { |
| 430 | 477 | projectNameEditedRef.current = true; |
| ... | ... | @@ -457,7 +504,7 @@ function ProjectDetail({ |
| 457 | 504 | ) ? ( |
| 458 | 505 | <SettingResetButton |
| 459 | 506 | label="project icon" |
| 460 | disabled={isSavingFavicon} | |
| 507 | disabled={isSavingFavicon || !canEditGroup} | |
| 461 | 508 | onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })} |
| 462 | 509 | /> |
| 463 | 510 | ) : null |
| ... | ... | @@ -470,7 +517,7 @@ function ProjectDetail({ |
| 470 | 517 | variant="outline" |
| 471 | 518 | type="button" |
| 472 | 519 | aria-label="Choose a project icon" |
| 473 | disabled={isSavingFavicon} | |
| 520 | disabled={isSavingFavicon || !canEditGroup} | |
| 474 | 521 | onClick={() => setIconPickerOpen(true)} |
| 475 | 522 | > |
| 476 | 523 | Choose icon |
| ... | ... | @@ -480,7 +527,7 @@ function ProjectDetail({ |
| 480 | 527 | variant="outline" |
| 481 | 528 | type="button" |
| 482 | 529 | aria-label="Choose a project icon file" |
| 483 | disabled={isSavingFavicon} | |
| 530 | disabled={isSavingFavicon || !canEditGroup} | |
| 484 | 531 | onClick={() => setFaviconPickerOpen(true)} |
| 485 | 532 | > |
| 486 | 533 | Choose file |
| ... | ... | @@ -512,6 +559,7 @@ function ProjectDetail({ |
| 512 | 559 | <Button |
| 513 | 560 | size="sm" |
| 514 | 561 | variant="destructive-outline" |
| 562 | disabled={!canEditGroup} | |
| 515 | 563 | onClick={() => void removeMembers(group.memberProjects)} |
| 516 | 564 | > |
| 517 | 565 | <Trash2Icon /> |
| ... | ... | @@ -535,10 +583,10 @@ function ProjectDetail({ |
| 535 | 583 | ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) } |
| 536 | 584 | : {})} |
| 537 | 585 | onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })} |
| 538 | open={faviconPickerOpen} | |
| 586 | open={faviconPickerOpen && canEditGroup} | |
| 539 | 587 | projectName={group.displayName} |
| 540 | 588 | /> |
| 541 | {iconPickerOpen ? ( | |
| 589 | {iconPickerOpen && canEditGroup ? ( | |
| 542 | 590 | <Suspense fallback={null}> |
| 543 | 591 | <ProjectIconPickerDialog |
| 544 | 592 | current={projectIcon} |
apps/web/src/components/settings/ProviderInstanceCard.tsx+14-15| ... | ... | @@ -478,6 +478,7 @@ interface ProviderInstanceCardProps { |
| 478 | 478 | readonly selected?: boolean | undefined; |
| 479 | 479 | readonly onSelect?: (() => void) | undefined; |
| 480 | 480 | readonly readOnly?: boolean | undefined; |
| 481 | readonly canWriteSettings?: boolean; | |
| 481 | 482 | readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; |
| 482 | 483 | /** |
| 483 | 484 | * Pass `undefined` to hide the delete footer entirely. Built-in default |
| ... | ... | @@ -546,6 +547,7 @@ export function ProviderInstanceCard({ |
| 546 | 547 | selected = false, |
| 547 | 548 | onSelect, |
| 548 | 549 | readOnly = false, |
| 550 | canWriteSettings = true, | |
| 549 | 551 | onUpdate, |
| 550 | 552 | onDelete, |
| 551 | 553 | headerAction, |
| ... | ... | @@ -1116,30 +1118,27 @@ export function ProviderInstanceCard({ |
| 1116 | 1118 | environment={genericEnvironment} |
| 1117 | 1119 | onChange={updateGenericEnvironment} |
| 1118 | 1120 | /> |
| 1119 | {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? ( | |
| 1120 | <AcpSessionManagementSection | |
| 1121 | environmentId={environmentId} | |
| 1122 | instanceId={instanceId} | |
| 1123 | provider={liveProvider} | |
| 1124 | projects={acpProjects} | |
| 1125 | readOnly={readOnly} | |
| 1126 | /> | |
| 1127 | ) : null} | |
| 1128 | 1121 | </SettingsSection> |
| 1122 | {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? ( | |
| 1123 | <AcpSessionManagementSection | |
| 1124 | environmentId={environmentId} | |
| 1125 | instanceId={instanceId} | |
| 1126 | provider={liveProvider} | |
| 1127 | projects={acpProjects} | |
| 1128 | readOnly={readOnly} | |
| 1129 | /> | |
| 1130 | ) : null} | |
| 1129 | 1131 | |
| 1130 | 1132 | {driverOption !== undefined ? ( |
| 1131 | <SettingsSection | |
| 1132 | title="Models" | |
| 1133 | inert={readOnly} | |
| 1134 | aria-disabled={readOnly || undefined} | |
| 1135 | className={readOnly ? "opacity-50 select-none" : undefined} | |
| 1136 | > | |
| 1133 | <SettingsSection title="Models"> | |
| 1137 | 1134 | <div className="px-3 py-3 sm:px-4"> |
| 1138 | 1135 | <p className="mb-3 text-xs text-muted-foreground"> |
| 1139 | 1136 | Favorites, visibility, and ordering are saved on this device. Custom models are saved |
| 1140 | 1137 | on the selected environment. |
| 1141 | 1138 | </p> |
| 1142 | 1139 | <ProviderModelsSection |
| 1140 | canManageCustomModels={!readOnly} | |
| 1141 | canWritePreferences={canWriteSettings} | |
| 1143 | 1142 | instanceId={instanceId} |
| 1144 | 1143 | driverKind={driverKind} |
| 1145 | 1144 | models={modelsForDisplay} |
apps/web/src/components/settings/ProviderModelsSection.tsx+22-10| ... | ... | @@ -131,6 +131,8 @@ interface ProviderModelsSectionProps { |
| 131 | 131 | * removed) via `onChange`. |
| 132 | 132 | */ |
| 133 | 133 | readonly customModels: ReadonlyArray<CustomModelDefinition>; |
| 134 | readonly canManageCustomModels: boolean; | |
| 135 | readonly canWritePreferences?: boolean; | |
| 134 | 136 | /** Server-returned model slugs hidden from the model picker. */ |
| 135 | 137 | readonly hiddenModels: ReadonlyArray<string>; |
| 136 | 138 | /** Model slugs favorited for this provider instance. */ |
| ... | ... | @@ -164,6 +166,8 @@ export function ProviderModelsSection({ |
| 164 | 166 | driverKind, |
| 165 | 167 | models, |
| 166 | 168 | customModels, |
| 169 | canManageCustomModels, | |
| 170 | canWritePreferences = true, | |
| 167 | 171 | hiddenModels, |
| 168 | 172 | favoriteModels, |
| 169 | 173 | modelOrder, |
| ... | ... | @@ -224,7 +228,7 @@ export function ProviderModelsSection({ |
| 224 | 228 | }, [displayModels]); |
| 225 | 229 | |
| 226 | 230 | const handleAdd = () => { |
| 227 | if (driverKind === "antigravity") return; | |
| 231 | if (!canManageCustomModels || driverKind === "antigravity") return; | |
| 228 | 232 | const normalized = normalizeCustomModelSlug(input); |
| 229 | 233 | if (!normalized) { |
| 230 | 234 | setError("Enter a model slug."); |
| ... | ... | @@ -260,6 +264,7 @@ export function ProviderModelsSection({ |
| 260 | 264 | }; |
| 261 | 265 | |
| 262 | 266 | const handleRemove = (slug: string) => { |
| 267 | if (!canManageCustomModels) return; | |
| 263 | 268 | if (editingSlug === slug) setEditingSlug(null); |
| 264 | 269 | onChange(customModels.filter((entry) => entry.slug !== slug)); |
| 265 | 270 | onModelOrderChange(modelOrder.filter((model) => model !== slug)); |
| ... | ... | @@ -268,18 +273,20 @@ export function ProviderModelsSection({ |
| 268 | 273 | }; |
| 269 | 274 | |
| 270 | 275 | const handleSaveEdit = (next: CustomModelDefinition) => { |
| 276 | if (!canManageCustomModels) return; | |
| 271 | 277 | onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); |
| 272 | 278 | setEditingSlug(null); |
| 273 | 279 | }; |
| 274 | 280 | |
| 275 | 281 | const setHidden = (slug: string, hidden: boolean) => { |
| 276 | if (hidden === hiddenModelSet.has(slug)) return; | |
| 282 | if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return; | |
| 277 | 283 | onHiddenModelsChange( |
| 278 | 284 | hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), |
| 279 | 285 | ); |
| 280 | 286 | }; |
| 281 | 287 | |
| 282 | 288 | const handleToggleFavorite = (slug: string) => { |
| 289 | if (!canWritePreferences) return; | |
| 283 | 290 | if (favoriteModelSet.has(slug)) { |
| 284 | 291 | onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); |
| 285 | 292 | return; |
| ... | ... | @@ -296,6 +303,7 @@ export function ProviderModelsSection({ |
| 296 | 303 | ? "hidden" |
| 297 | 304 | : "visible"; |
| 298 | 305 | const handleMove = (slug: string, direction: -1 | 1) => { |
| 306 | if (!canWritePreferences) return; | |
| 299 | 307 | const index = displayModels.findIndex((model) => model.slug === slug); |
| 300 | 308 | const nextIndex = index + direction; |
| 301 | 309 | if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; |
| ... | ... | @@ -314,6 +322,7 @@ export function ProviderModelsSection({ |
| 314 | 322 | <Button |
| 315 | 323 | size="icon-micro" |
| 316 | 324 | variant="ghost-muted" |
| 325 | disabled={!canWritePreferences} | |
| 317 | 326 | onClick={() => handleToggleFavorite(model.slug)} |
| 318 | 327 | aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ |
| 319 | 328 | isFavorite ? "from" : "to" |
| ... | ... | @@ -348,7 +357,7 @@ export function ProviderModelsSection({ |
| 348 | 357 | <Button |
| 349 | 358 | size="icon-micro" |
| 350 | 359 | variant="ghost-muted" |
| 351 | disabled={!options.canMoveUp} | |
| 360 | disabled={!canWritePreferences || !options.canMoveUp} | |
| 352 | 361 | onClick={() => handleMove(model.slug, -1)} |
| 353 | 362 | aria-label={`Move ${model.name} up`} |
| 354 | 363 | /> |
| ... | ... | @@ -364,7 +373,7 @@ export function ProviderModelsSection({ |
| 364 | 373 | <Button |
| 365 | 374 | size="icon-micro" |
| 366 | 375 | variant="ghost-muted" |
| 367 | disabled={!options.canMoveDown} | |
| 376 | disabled={!canWritePreferences || !options.canMoveDown} | |
| 368 | 377 | onClick={() => handleMove(model.slug, 1)} |
| 369 | 378 | aria-label={`Move ${model.name} down`} |
| 370 | 379 | /> |
| ... | ... | @@ -384,10 +393,12 @@ export function ProviderModelsSection({ |
| 384 | 393 | <Button |
| 385 | 394 | size="icon-micro" |
| 386 | 395 | variant="ghost-muted" |
| 396 | disabled={!canManageCustomModels} | |
| 387 | 397 | aria-label={`Edit ${model.slug}`} |
| 388 | onClick={() => | |
| 389 | setEditingSlug((current) => (current === model.slug ? null : model.slug)) | |
| 390 | } | |
| 398 | onClick={() => { | |
| 399 | if (!canManageCustomModels) return; | |
| 400 | setEditingSlug((current) => (current === model.slug ? null : model.slug)); | |
| 401 | }} | |
| 391 | 402 | /> |
| 392 | 403 | } |
| 393 | 404 | > |
| ... | ... | @@ -401,6 +412,7 @@ export function ProviderModelsSection({ |
| 401 | 412 | <Button |
| 402 | 413 | size="icon-micro" |
| 403 | 414 | variant="ghost-muted" |
| 415 | disabled={!canManageCustomModels} | |
| 404 | 416 | aria-label={`Remove ${model.slug}`} |
| 405 | 417 | onClick={() => handleRemove(model.slug)} |
| 406 | 418 | /> |
| ... | ... | @@ -430,7 +442,7 @@ export function ProviderModelsSection({ |
| 430 | 442 | <Switch |
| 431 | 443 | size="sm" |
| 432 | 444 | checked={!isHidden} |
| 433 | disabled={model.isCustom} | |
| 445 | disabled={!canWritePreferences || model.isCustom} | |
| 434 | 446 | onCheckedChange={(checked) => setHidden(model.slug, !checked)} |
| 435 | 447 | aria-label={`Show ${model.name} in the model picker`} |
| 436 | 448 | /> |
| ... | ... | @@ -562,7 +574,7 @@ export function ProviderModelsSection({ |
| 562 | 574 | const previous = visibleModels[index - 1]; |
| 563 | 575 | const startsGroup = previous === undefined || groupOf(previous) !== group; |
| 564 | 576 | const editingEntry = |
| 565 | model.isCustom && editingSlug === model.slug | |
| 577 | canManageCustomModels && model.isCustom && editingSlug === model.slug | |
| 566 | 578 | ? customModels.find((entry) => entry.slug === model.slug) |
| 567 | 579 | : undefined; |
| 568 | 580 | return ( |
| ... | ... | @@ -593,7 +605,7 @@ export function ProviderModelsSection({ |
| 593 | 605 | })} |
| 594 | 606 | </div> |
| 595 | 607 | |
| 596 | {driverKind === "antigravity" ? null : isAdding ? ( | |
| 608 | {driverKind === "antigravity" || !canManageCustomModels ? null : isAdding ? ( | |
| 597 | 609 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> |
| 598 | 610 | <Input |
| 599 | 611 | id={`provider-instance-${instanceId}-custom-model`} |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+39-1| ... | ... | @@ -26,6 +26,8 @@ const commands = vi.hoisted(() => ({ |
| 26 | 26 | updateProvider: vi.fn(), |
| 27 | 27 | uninstall: vi.fn(), |
| 28 | 28 | acceptUrlAuth: vi.fn(), |
| 29 | canManageProviders: true, | |
| 30 | canWriteSettings: true, | |
| 29 | 31 | })); |
| 30 | 32 | |
| 31 | 33 | const settingsState = vi.hoisted(() => ({ |
| ... | ... | @@ -118,6 +120,20 @@ vi.mock("../../environments/primary", () => ({ |
| 118 | 120 | |
| 119 | 121 | vi.mock("../../state/session", () => ({ |
| 120 | 122 | useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), |
| 123 | useEnvironmentScope: (environmentId: EnvironmentId, scope: string) => | |
| 124 | environmentId === "remote-device" && | |
| 125 | (scope === "providers:manage" | |
| 126 | ? commands.canManageProviders | |
| 127 | : scope === "settings:write" | |
| 128 | ? commands.canWriteSettings | |
| 129 | : scope === "orchestration:read"), | |
| 130 | readEnvironmentScope: (environmentId: EnvironmentId, scope: string) => | |
| 131 | environmentId === "remote-device" && | |
| 132 | (scope === "providers:manage" | |
| 133 | ? commands.canManageProviders | |
| 134 | : scope === "settings:write" | |
| 135 | ? commands.canWriteSettings | |
| 136 | : scope === "orchestration:read"), | |
| 121 | 137 | })); |
| 122 | 138 | |
| 123 | 139 | vi.mock("../../state/entities", () => ({ |
| ... | ... | @@ -125,6 +141,7 @@ vi.mock("../../state/entities", () => ({ |
| 125 | 141 | })); |
| 126 | 142 | |
| 127 | 143 | import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; |
| 144 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | |
| 128 | 145 | |
| 129 | 146 | const environmentId = EnvironmentId.make("remote-device"); |
| 130 | 147 | const codexId = ProviderInstanceId.make("codex"); |
| ... | ... | @@ -209,6 +226,8 @@ describe("EnvironmentProviderSettings routing", () => { |
| 209 | 226 | settingsState.mutateProviderInstance |
| 210 | 227 | .mockReset() |
| 211 | 228 | .mockResolvedValue({ _tag: "Success", value: {} }); |
| 229 | commands.canManageProviders = true; | |
| 230 | commands.canWriteSettings = true; | |
| 212 | 231 | commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); |
| 213 | 232 | commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); |
| 214 | 233 | commands.uninstall.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); |
| ... | ... | @@ -355,6 +374,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 355 | 374 | }); |
| 356 | 375 | |
| 357 | 376 | it("keeps provider selection available while write controls are read only", () => { |
| 377 | commands.canWriteSettings = false; | |
| 358 | 378 | settingsState.value = { |
| 359 | 379 | ...DEFAULT_UNIFIED_SETTINGS, |
| 360 | 380 | providerInstances: { |
| ... | ... | @@ -388,7 +408,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 388 | 408 | const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); |
| 389 | 409 | expect(notice).not.toBeNull(); |
| 390 | 410 | |
| 391 | expect(visitElements(panel, isRefreshButton)).toBeNull(); | |
| 411 | expect(visitElements(panel, isRefreshButton)).not.toBeNull(); | |
| 392 | 412 | expect(visitElements(panel, isAddProviderButton)).toBeNull(); |
| 393 | 413 | }); |
| 394 | 414 | |
| ... | ... | @@ -403,6 +423,24 @@ describe("EnvironmentProviderSettings routing", () => { |
| 403 | 423 | expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); |
| 404 | 424 | }); |
| 405 | 425 | |
| 426 | it("removes an open add-instance dialog when the provider grant is revoked", () => { | |
| 427 | let panel = renderPanel(); | |
| 428 | const add = visitElements(panel, isAddProviderButton); | |
| 429 | if (!add) throw new Error("Missing Add provider action."); | |
| 430 | (add.props.onClick as () => void)(); | |
| 431 | panel = renderPanel(); | |
| 432 | expect( | |
| 433 | visitElements(panel, (element) => element.type === AddProviderInstanceDialog), | |
| 434 | ).not.toBeNull(); | |
| 435 | ||
| 436 | commands.canManageProviders = false; | |
| 437 | panel = renderPanel({ readOnly: true }); | |
| 438 | expect( | |
| 439 | visitElements(panel, (element) => element.type === AddProviderInstanceDialog), | |
| 440 | ).toBeNull(); | |
| 441 | expect(settingsState.updateSettings).not.toHaveBeenCalled(); | |
| 442 | }); | |
| 443 | ||
| 406 | 444 | it("keeps Advanced visible when search targets the provider health interval", () => { |
| 407 | 445 | let panel = renderPanel(); |
| 408 | 446 | expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull(); |
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+21-14| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { |
| ... | ... | @@ -136,7 +136,7 @@ describe("provider environment access", () => { |
| 136 | 136 | describe("primary operate access", () => { |
| 137 | 137 | const authenticated = { |
| 138 | 138 | authenticated: true as const, |
| 139 | scopes: [AuthOrchestrationOperateScope], | |
| 139 | scopes: [AuthProvidersManageScope], | |
| 140 | 140 | }; |
| 141 | 141 | |
| 142 | 142 | it("keeps cached session data authoritative while SWR revalidates", () => { |
| ... | ... | @@ -163,7 +163,7 @@ describe("primary operate access", () => { |
| 163 | 163 | ).toBe("pending"); |
| 164 | 164 | }); |
| 165 | 165 | |
| 166 | it("treats a failed session fetch as a transport problem, not a denial", () => { | |
| 166 | it("denies writes when the session fetch fails", () => { | |
| 167 | 167 | expect( |
| 168 | 168 | resolvePrimaryOperateAccess({ |
| 169 | 169 | isPrimary: true, |
| ... | ... | @@ -172,7 +172,7 @@ describe("primary operate access", () => { |
| 172 | 172 | isPending: false, |
| 173 | 173 | hasError: true, |
| 174 | 174 | }), |
| 175 | ).toBe("granted"); | |
| 175 | ).toBe("denied"); | |
| 176 | 176 | }); |
| 177 | 177 | |
| 178 | 178 | it("denies unauthenticated sessions and sessions without the operate scope", () => { |
| ... | ... | @@ -205,7 +205,7 @@ describe("primary operate access", () => { |
| 205 | 205 | ).toBe("denied"); |
| 206 | 206 | }); |
| 207 | 207 | |
| 208 | it("grants desktop bridge and remote environments without blocking on the primary session", () => { | |
| 208 | it("waits for explicit grants on desktop and remote environments", () => { | |
| 209 | 209 | expect( |
| 210 | 210 | resolvePrimaryOperateAccess({ |
| 211 | 211 | isPrimary: true, |
| ... | ... | @@ -214,7 +214,7 @@ describe("primary operate access", () => { |
| 214 | 214 | isPending: true, |
| 215 | 215 | hasError: false, |
| 216 | 216 | }), |
| 217 | ).toBe("granted"); | |
| 217 | ).toBe("pending"); | |
| 218 | 218 | expect( |
| 219 | 219 | resolvePrimaryOperateAccess({ |
| 220 | 220 | isPrimary: false, |
| ... | ... | @@ -223,15 +223,24 @@ describe("primary operate access", () => { |
| 223 | 223 | isPending: true, |
| 224 | 224 | hasError: false, |
| 225 | 225 | }), |
| 226 | ).toBe("granted"); | |
| 226 | ).toBe("pending"); | |
| 227 | 227 | }); |
| 228 | 228 | }); |
| 229 | 229 | |
| 230 | 230 | describe("remote operate access", () => { |
| 231 | it("does not treat the old orchestration grant as provider management", () => { | |
| 232 | expect( | |
| 233 | resolveRemoteOperateAccess({ | |
| 234 | session: { authenticated: true, scopes: ["orchestration:operate"] }, | |
| 235 | isPending: false, | |
| 236 | hasError: false, | |
| 237 | }), | |
| 238 | ).toBe("denied"); | |
| 239 | }); | |
| 231 | 240 | it("derives access from the environment session's granted scopes", () => { |
| 232 | 241 | expect( |
| 233 | 242 | resolveRemoteOperateAccess({ |
| 234 | session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, | |
| 243 | session: { authenticated: true, scopes: [AuthProvidersManageScope] }, | |
| 235 | 244 | isPending: false, |
| 236 | 245 | hasError: false, |
| 237 | 246 | }), |
| ... | ... | @@ -258,18 +267,16 @@ describe("remote operate access", () => { |
| 258 | 267 | ); |
| 259 | 268 | expect( |
| 260 | 269 | resolveRemoteOperateAccess({ |
| 261 | session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, | |
| 270 | session: { authenticated: true, scopes: [AuthProvidersManageScope] }, | |
| 262 | 271 | isPending: true, |
| 263 | 272 | hasError: false, |
| 264 | 273 | }), |
| 265 | 274 | ).toBe("granted"); |
| 266 | 275 | }); |
| 267 | 276 | |
| 268 | it("stays optimistic when the session fetch fails or an older server omits scopes", () => { | |
| 269 | // Transport failures and pre-scope-reporting servers are not permission | |
| 270 | // decisions; the environment RPC layer still rejects unauthorized writes. | |
| 277 | it("denies writes when the session fetch fails or scopes are missing", () => { | |
| 271 | 278 | expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( |
| 272 | "granted", | |
| 279 | "denied", | |
| 273 | 280 | ); |
| 274 | 281 | expect( |
| 275 | 282 | resolveRemoteOperateAccess({ |
| ... | ... | @@ -277,6 +284,6 @@ describe("remote operate access", () => { |
| 277 | 284 | isPending: false, |
| 278 | 285 | hasError: false, |
| 279 | 286 | }), |
| 280 | ).toBe("granted"); | |
| 287 | ).toBe("denied"); | |
| 281 | 288 | }); |
| 282 | 289 | }); |
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+9-51| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | |
| 3 | AuthProvidersManageScope, | |
| 4 | 4 | type AuthSessionState, |
| 5 | 5 | type EnvironmentId, |
| 6 | 6 | } from "@t3tools/contracts"; |
| ... | ... | @@ -74,46 +74,19 @@ export type ProviderEnvironmentAccess = |
| 74 | 74 | */ |
| 75 | 75 | export type ProviderOperateAccess = "granted" | "denied" | "pending"; |
| 76 | 76 | |
| 77 | /** | |
| 78 | * Resolve operate access from an environment's `/api/auth/session` answer. | |
| 79 | * | |
| 80 | * Cached session data wins over an in-flight revalidation. The session atoms | |
| 81 | * are SWR-backed, so they report `isPending` on every background refresh; | |
| 82 | * treating that as unknown would flip a working panel back to loading and | |
| 83 | * discard in-progress edits. | |
| 84 | * | |
| 85 | * `missingScopesAccess` decides the case where the session resolved but did | |
| 86 | * not report scopes: the primary serves the web app itself so its server | |
| 87 | * always reports them (absence means denial), while a remote device may run an | |
| 88 | * older server version that predates scope reporting, where denial would lock | |
| 89 | * out a legitimate session. The environment RPC layer stays authoritative | |
| 90 | * either way. | |
| 91 | */ | |
| 77 | /** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */ | |
| 92 | 78 | function resolveSessionOperateAccess(input: { |
| 93 | 79 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; |
| 94 | 80 | readonly isPending: boolean; |
| 95 | 81 | readonly hasError: boolean; |
| 96 | readonly missingScopesAccess: "granted" | "denied"; | |
| 97 | 82 | }): ProviderOperateAccess { |
| 98 | if (input.session === null) { | |
| 99 | if (input.isPending) { | |
| 100 | return "pending"; | |
| 101 | } | |
| 102 | // A failed session fetch is a transport problem, not a permission | |
| 103 | // decision — locking the panel read-only would misreport it. Stay | |
| 104 | // optimistic; the environment RPC layer still rejects unauthorized writes. | |
| 105 | return input.hasError ? "granted" : "denied"; | |
| 106 | } | |
| 107 | if (!input.session.authenticated) { | |
| 108 | return "denied"; | |
| 109 | } | |
| 110 | if (input.session.scopes === undefined) { | |
| 111 | return input.missingScopesAccess; | |
| 112 | } | |
| 113 | return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; | |
| 83 | if (input.hasError) return "denied"; | |
| 84 | if (input.session === null) return input.isPending ? "pending" : "denied"; | |
| 85 | return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope) | |
| 86 | ? "granted" | |
| 87 | : "denied"; | |
| 114 | 88 | } |
| 115 | 89 | |
| 116 | /** Operate access for the primary environment's own browser session. */ | |
| 117 | 90 | export function resolvePrimaryOperateAccess(input: { |
| 118 | 91 | readonly isPrimary: boolean; |
| 119 | 92 | readonly hasDesktopBridge: boolean; |
| ... | ... | @@ -121,30 +94,15 @@ export function resolvePrimaryOperateAccess(input: { |
| 121 | 94 | readonly isPending: boolean; |
| 122 | 95 | readonly hasError: boolean; |
| 123 | 96 | }): ProviderOperateAccess { |
| 124 | if (!input.isPrimary || input.hasDesktopBridge) { | |
| 125 | return "granted"; | |
| 126 | } | |
| 127 | return resolveSessionOperateAccess({ | |
| 128 | session: input.session, | |
| 129 | isPending: input.isPending, | |
| 130 | hasError: input.hasError, | |
| 131 | missingScopesAccess: "denied", | |
| 132 | }); | |
| 97 | return resolveSessionOperateAccess(input); | |
| 133 | 98 | } |
| 134 | 99 | |
| 135 | /** | |
| 136 | * Operate access for a non-primary environment, derived from the scopes its | |
| 137 | * `/api/auth/session` endpoint reports for this client's credential. | |
| 138 | */ | |
| 139 | 100 | export function resolveRemoteOperateAccess(input: { |
| 140 | 101 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; |
| 141 | 102 | readonly isPending: boolean; |
| 142 | 103 | readonly hasError: boolean; |
| 143 | 104 | }): ProviderOperateAccess { |
| 144 | return resolveSessionOperateAccess({ | |
| 145 | ...input, | |
| 146 | missingScopesAccess: "granted", | |
| 147 | }); | |
| 105 | return resolveSessionOperateAccess(input); | |
| 148 | 106 | } |
| 149 | 107 | |
| 150 | 108 | export function classifyProviderEnvironmentAccess(input: { |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+67-110| ... | ... | @@ -1,5 +1,11 @@ |
| 1 | 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | 2 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 3 | import { | |
| 4 | AuthSettingsWriteScope, | |
| 5 | AuthProvidersManageScope, | |
| 6 | AuthOrchestrationReadScope, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; | |
| 3 | 9 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 10 | import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; |
| 5 | 11 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| ... | ... | @@ -31,8 +37,6 @@ import { PlusIcon } from "lucide-react"; |
| 31 | 37 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 32 | 38 | |
| 33 | 39 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; |
| 34 | import { isElectron } from "../../env"; | |
| 35 | import { usePrimarySessionState } from "../../environments/primary"; | |
| 36 | 40 | import { |
| 37 | 41 | useEnvironmentSettings, |
| 38 | 42 | usePersistEnvironmentProviderInstanceMutation, |
| ... | ... | @@ -115,7 +119,6 @@ import { |
| 115 | 119 | isProviderSettingsEnvironmentAvailable, |
| 116 | 120 | type ProviderEnvironmentAccess, |
| 117 | 121 | type ProviderOperateAccess, |
| 118 | resolvePrimaryOperateAccess, | |
| 119 | 122 | resolveRemoteOperateAccess, |
| 120 | 123 | resolveSelectedProviderEnvironmentId, |
| 121 | 124 | } from "./ProviderSettingsPanel.logic"; |
| ... | ... | @@ -465,28 +468,6 @@ function SelectedEnvironmentProviderSettings({ |
| 465 | 468 | readonly deviceTabs?: ReactNode; |
| 466 | 469 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 467 | 470 | }) { |
| 468 | const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; | |
| 469 | if (isPrimary) { | |
| 470 | // The desktop app owns its primary server outright; a browser session | |
| 471 | // checks the scopes its cookie session was granted. | |
| 472 | if (isElectron) { | |
| 473 | return ( | |
| 474 | <AccessGatedProviderSettings | |
| 475 | environment={environment} | |
| 476 | operateAccess="granted" | |
| 477 | deviceTabs={deviceTabs} | |
| 478 | targetInstanceId={targetInstanceId} | |
| 479 | /> | |
| 480 | ); | |
| 481 | } | |
| 482 | return ( | |
| 483 | <PrimarySessionGatedProviderSettings | |
| 484 | environment={environment} | |
| 485 | deviceTabs={deviceTabs} | |
| 486 | targetInstanceId={targetInstanceId} | |
| 487 | /> | |
| 488 | ); | |
| 489 | } | |
| 490 | 471 | return ( |
| 491 | 472 | <RemoteSessionGatedProviderSettings |
| 492 | 473 | environment={environment} |
| ... | ... | @@ -496,33 +477,6 @@ function SelectedEnvironmentProviderSettings({ |
| 496 | 477 | ); |
| 497 | 478 | } |
| 498 | 479 | |
| 499 | function PrimarySessionGatedProviderSettings({ | |
| 500 | environment, | |
| 501 | deviceTabs, | |
| 502 | targetInstanceId, | |
| 503 | }: { | |
| 504 | readonly environment: EnvironmentPresentation; | |
| 505 | readonly deviceTabs?: ReactNode; | |
| 506 | readonly targetInstanceId?: ProviderInstanceId | undefined; | |
| 507 | }) { | |
| 508 | const primarySessionState = usePrimarySessionState(); | |
| 509 | const operateAccess = resolvePrimaryOperateAccess({ | |
| 510 | isPrimary: true, | |
| 511 | hasDesktopBridge: false, | |
| 512 | session: primarySessionState.data, | |
| 513 | isPending: primarySessionState.isPending, | |
| 514 | hasError: primarySessionState.error !== null, | |
| 515 | }); | |
| 516 | return ( | |
| 517 | <AccessGatedProviderSettings | |
| 518 | environment={environment} | |
| 519 | operateAccess={operateAccess} | |
| 520 | deviceTabs={deviceTabs} | |
| 521 | targetInstanceId={targetInstanceId} | |
| 522 | /> | |
| 523 | ); | |
| 524 | } | |
| 525 | ||
| 526 | 480 | function RemoteSessionGatedProviderSettings({ |
| 527 | 481 | environment, |
| 528 | 482 | deviceTabs, |
| ... | ... | @@ -597,15 +551,15 @@ export function EnvironmentProviderSettings({ |
| 597 | 551 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 598 | 552 | /** |
| 599 | 553 | * Grey out and freeze every write control when this session's credential |
| 600 | * lacks `orchestration:operate` on the environment. Selecting providers | |
| 554 | * lacks `providers:manage` on the environment. Selecting providers | |
| 601 | 555 | * still works so the real configuration stays readable; switches, forms, |
| 602 | * and the health interval are inert so no write is offered and then rejected. | |
| 556 | * are inert so no write is offered and then rejected. | |
| 603 | 557 | */ |
| 604 | 558 | readonly readOnly?: boolean; |
| 605 | 559 | }) { |
| 606 | 560 | const settings = useEnvironmentSettings(environmentId); |
| 607 | // Provider instances hold per-machine credentials and binaries, so this | |
| 608 | // page always edits exactly the environment it displays. | |
| 561 | const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); | |
| 562 | const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); | |
| 609 | 563 | const updateSettings = useUpdateEnvironmentSettings(environmentId); |
| 610 | 564 | const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); |
| 611 | 565 | const updateClientSettings = useUpdateClientSettings(); |
| ... | ... | @@ -700,7 +654,8 @@ export function EnvironmentProviderSettings({ |
| 700 | 654 | : null; |
| 701 | 655 | |
| 702 | 656 | const refreshProviders = useCallback(() => { |
| 703 | if (refreshingRef.current) return; | |
| 657 | if (refreshingRef.current || !readEnvironmentScope(environmentId, AuthOrchestrationReadScope)) | |
| 658 | return; | |
| 704 | 659 | refreshingRef.current = true; |
| 705 | 660 | setIsRefreshingProviders(true); |
| 706 | 661 | void (async () => { |
| ... | ... | @@ -725,6 +680,7 @@ export function EnvironmentProviderSettings({ |
| 725 | 680 | candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">, |
| 726 | 681 | targetVersion?: string, |
| 727 | 682 | ) => { |
| 683 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 728 | 684 | // Ref-based re-entry guard, mirroring refreshProviders: a state updater |
| 729 | 685 | // may run after this function returns, so it cannot gate the dispatch. |
| 730 | 686 | if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { |
| ... | ... | @@ -1062,6 +1018,7 @@ export function EnvironmentProviderSettings({ |
| 1062 | 1018 | /> |
| 1063 | 1019 | ) : undefined |
| 1064 | 1020 | } |
| 1021 | canWriteSettings={canWriteSettings} | |
| 1065 | 1022 | setup={ |
| 1066 | 1023 | mode === "editor" && row.driver === "antigravity" ? ( |
| 1067 | 1024 | <ProviderSetupSection |
| ... | ... | @@ -1125,7 +1082,10 @@ export function EnvironmentProviderSettings({ |
| 1125 | 1082 | onUpdate={(next) => { |
| 1126 | 1083 | const wasEnabled = resolveProviderInstanceEnabled(row.instance); |
| 1127 | 1084 | const isDisabling = next.enabled === false && wasEnabled; |
| 1128 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; | |
| 1085 | const shouldClearTextGen = | |
| 1086 | isDisabling && | |
| 1087 | textGenInstanceId === row.instanceId && | |
| 1088 | readEnvironmentScope(environmentId, AuthSettingsWriteScope); | |
| 1129 | 1089 | updateProviderInstance( |
| 1130 | 1090 | row, |
| 1131 | 1091 | next, |
| ... | ... | @@ -1197,53 +1157,47 @@ export function EnvironmentProviderSettings({ |
| 1197 | 1157 | headerAction={ |
| 1198 | 1158 | <div className="flex min-w-0 items-center gap-2"> |
| 1199 | 1159 | <ProviderUpdatesAction /> |
| 1200 | {readOnly ? ( | |
| 1201 | <span className="min-w-0 truncate text-xs text-muted-foreground"> | |
| 1202 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 1203 | </span> | |
| 1204 | ) : ( | |
| 1205 | <> | |
| 1206 | <Tooltip> | |
| 1207 | <TooltipTrigger | |
| 1208 | render={ | |
| 1209 | <Button | |
| 1210 | size="xs" | |
| 1211 | variant="ghost-muted" | |
| 1212 | disabled={isRefreshingProviders} | |
| 1213 | aria-busy={isRefreshingProviders} | |
| 1214 | onClick={() => void refreshProviders()} | |
| 1215 | > | |
| 1216 | <RefreshIcon refreshing={isRefreshingProviders} /> | |
| 1217 | <span className="sr-only">Refresh provider status</span> | |
| 1218 | <span className="hidden min-w-0 truncate sm:inline"> | |
| 1219 | {isRefreshingProviders ? ( | |
| 1220 | "Refreshing providers" | |
| 1221 | ) : ( | |
| 1222 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 1223 | )} | |
| 1224 | </span> | |
| 1225 | </Button> | |
| 1226 | } | |
| 1227 | /> | |
| 1228 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 1229 | </Tooltip> | |
| 1230 | <Tooltip> | |
| 1231 | <TooltipTrigger | |
| 1232 | render={ | |
| 1233 | <Button | |
| 1234 | size="icon-xs" | |
| 1235 | variant="ghost-muted" | |
| 1236 | onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 1237 | aria-label="Add provider" | |
| 1238 | > | |
| 1239 | <PlusIcon /> | |
| 1240 | </Button> | |
| 1241 | } | |
| 1242 | /> | |
| 1243 | <TooltipPopup side="top">Add provider</TooltipPopup> | |
| 1244 | </Tooltip> | |
| 1245 | </> | |
| 1246 | )} | |
| 1160 | <Tooltip> | |
| 1161 | <TooltipTrigger | |
| 1162 | render={ | |
| 1163 | <Button | |
| 1164 | size="xs" | |
| 1165 | variant="ghost-muted" | |
| 1166 | disabled={isRefreshingProviders || !canRefreshProviders} | |
| 1167 | aria-busy={isRefreshingProviders} | |
| 1168 | onClick={() => void refreshProviders()} | |
| 1169 | > | |
| 1170 | <RefreshIcon refreshing={isRefreshingProviders} /> | |
| 1171 | <span className="sr-only">Refresh provider status</span> | |
| 1172 | <span className="hidden min-w-0 truncate sm:inline"> | |
| 1173 | {isRefreshingProviders ? ( | |
| 1174 | "Refreshing providers" | |
| 1175 | ) : ( | |
| 1176 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 1177 | )} | |
| 1178 | </span> | |
| 1179 | </Button> | |
| 1180 | } | |
| 1181 | /> | |
| 1182 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 1183 | </Tooltip> | |
| 1184 | {!readOnly ? ( | |
| 1185 | <Tooltip> | |
| 1186 | <TooltipTrigger | |
| 1187 | render={ | |
| 1188 | <Button | |
| 1189 | size="icon-xs" | |
| 1190 | variant="ghost-muted" | |
| 1191 | onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 1192 | aria-label="Add provider" | |
| 1193 | > | |
| 1194 | <PlusIcon /> | |
| 1195 | </Button> | |
| 1196 | } | |
| 1197 | /> | |
| 1198 | <TooltipPopup side="top">Add provider</TooltipPopup> | |
| 1199 | </Tooltip> | |
| 1200 | ) : null} | |
| 1247 | 1201 | </div> |
| 1248 | 1202 | } |
| 1249 | 1203 | > |
| ... | ... | @@ -1318,7 +1272,10 @@ export function EnvironmentProviderSettings({ |
| 1318 | 1272 | description="Refresh provider status, versions, and models in the background. Set to 0 to disable." |
| 1319 | 1273 | resetAction={ |
| 1320 | 1274 | providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( |
| 1321 | <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}> | |
| 1275 | <span | |
| 1276 | inert={!canWriteSettings} | |
| 1277 | className={!canWriteSettings ? "opacity-50" : undefined} | |
| 1278 | > | |
| 1322 | 1279 | <SettingResetButton |
| 1323 | 1280 | label="provider health check interval" |
| 1324 | 1281 | onClick={() => |
| ... | ... | @@ -1336,11 +1293,11 @@ export function EnvironmentProviderSettings({ |
| 1336 | 1293 | } |
| 1337 | 1294 | control={ |
| 1338 | 1295 | <div |
| 1339 | inert={readOnly} | |
| 1340 | aria-disabled={readOnly || undefined} | |
| 1296 | inert={!canWriteSettings} | |
| 1297 | aria-disabled={!canWriteSettings || undefined} | |
| 1341 | 1298 | className={cn( |
| 1342 | 1299 | "flex shrink-0 items-center gap-2", |
| 1343 | readOnly && "opacity-50 select-none", | |
| 1300 | !canWriteSettings && "opacity-50 select-none", | |
| 1344 | 1301 | )} |
| 1345 | 1302 | > |
| 1346 | 1303 | <NumberField |
| ... | ... | @@ -1375,7 +1332,7 @@ export function EnvironmentProviderSettings({ |
| 1375 | 1332 | /> |
| 1376 | 1333 | </SettingsSection> |
| 1377 | 1334 | |
| 1378 | {isAddInstanceDialogOpen ? ( | |
| 1335 | {isAddInstanceDialogOpen && !readOnly ? ( | |
| 1379 | 1336 | <AddProviderInstanceDialog |
| 1380 | 1337 | open |
| 1381 | 1338 | environmentId={environmentId} |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+28-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { ProcessSignalActions } from "./ProcessSignalActions"; |
| 2 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; | |
| 2 | 3 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 3 | 4 | import { |
| 4 | 5 | ActivityIcon, |
| ... | ... | @@ -40,6 +41,7 @@ import { |
| 40 | 41 | import { cn } from "../../lib/utils"; |
| 41 | 42 | import { ensureLocalApi } from "../../localApi"; |
| 42 | 43 | import { serverEnvironment } from "../../state/server"; |
| 44 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 43 | 45 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 44 | 46 | import { formatRelativeTime } from "../../timestampFormat"; |
| 45 | 47 | import { Button } from "../ui/button"; |
| ... | ... | @@ -520,10 +522,12 @@ function canSignalProcess(process: ResourceTelemetryProcess): boolean { |
| 520 | 522 | |
| 521 | 523 | function ProcessActions({ |
| 522 | 524 | process, |
| 525 | canMaintainEnvironment, | |
| 523 | 526 | signalingKeys, |
| 524 | 527 | onSignal, |
| 525 | 528 | }: { |
| 526 | 529 | process: ResourceTelemetryProcess; |
| 530 | canMaintainEnvironment: boolean; | |
| 527 | 531 | signalingKeys: ReadonlySet<string>; |
| 528 | 532 | onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; |
| 529 | 533 | }) { |
| ... | ... | @@ -532,16 +536,21 @@ function ProcessActions({ |
| 532 | 536 | } |
| 533 | 537 | const isSignaling = signalingKeys.has(processIdentityKey(process)); |
| 534 | 538 | return ( |
| 535 | <ProcessSignalActions disabled={isSignaling} onSignal={(signal) => onSignal(process, signal)} /> | |
| 539 | <ProcessSignalActions | |
| 540 | disabled={!canMaintainEnvironment || isSignaling} | |
| 541 | onSignal={(signal) => onSignal(process, signal)} | |
| 542 | /> | |
| 536 | 543 | ); |
| 537 | 544 | } |
| 538 | 545 | |
| 539 | 546 | function ProcessTable({ |
| 540 | 547 | processes, |
| 548 | canMaintainEnvironment, | |
| 541 | 549 | signalingKeys, |
| 542 | 550 | onSignal, |
| 543 | 551 | }: { |
| 544 | 552 | processes: ReadonlyArray<ResourceTelemetryProcess>; |
| 553 | canMaintainEnvironment: boolean; | |
| 545 | 554 | signalingKeys: ReadonlySet<string>; |
| 546 | 555 | onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; |
| 547 | 556 | }) { |
| ... | ... | @@ -650,6 +659,7 @@ function ProcessTable({ |
| 650 | 659 | <td className="px-2 py-2 text-right sm:pr-4"> |
| 651 | 660 | <ProcessActions |
| 652 | 661 | process={process} |
| 662 | canMaintainEnvironment={canMaintainEnvironment} | |
| 653 | 663 | signalingKeys={signalingKeys} |
| 654 | 664 | onSignal={onSignal} |
| 655 | 665 | /> |
| ... | ... | @@ -820,6 +830,7 @@ export function ResourceTelemetryDiagnostics({ |
| 820 | 830 | const [windowMs, setWindowMs] = useState(15 * 60_000); |
| 821 | 831 | const selectedWindow = |
| 822 | 832 | HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; |
| 833 | const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); | |
| 823 | 834 | const telemetry = useResourceTelemetry(environmentId); |
| 824 | 835 | const retryTelemetry = telemetry.retry; |
| 825 | 836 | const history = useResourceTelemetryHistory( |
| ... | ... | @@ -848,7 +859,11 @@ export function ResourceTelemetryDiagnostics({ |
| 848 | 859 | const signalProcess = useCallback( |
| 849 | 860 | async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { |
| 850 | 861 | const targetEnvironmentId = environmentIdRef.current; |
| 851 | if (targetEnvironmentId === null) return; | |
| 862 | if ( | |
| 863 | targetEnvironmentId === null || | |
| 864 | !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope) | |
| 865 | ) | |
| 866 | return; | |
| 852 | 867 | const identityKey = processIdentityKey(process); |
| 853 | 868 | if (signalingKeysRef.current.has(identityKey)) return; |
| 854 | 869 | const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey); |
| ... | ... | @@ -882,7 +897,10 @@ export function ResourceTelemetryDiagnostics({ |
| 882 | 897 | return; |
| 883 | 898 | } |
| 884 | 899 | } |
| 885 | if (environmentIdRef.current !== targetEnvironmentId) { | |
| 900 | if ( | |
| 901 | environmentIdRef.current !== targetEnvironmentId || | |
| 902 | !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope) | |
| 903 | ) { | |
| 886 | 904 | clearSignaling(); |
| 887 | 905 | return; |
| 888 | 906 | } |
| ... | ... | @@ -924,6 +942,12 @@ export function ResourceTelemetryDiagnostics({ |
| 924 | 942 | ); |
| 925 | 943 | |
| 926 | 944 | const retryCollector = useCallback(() => { |
| 945 | const environmentId = environmentIdRef.current; | |
| 946 | if ( | |
| 947 | environmentId === null || | |
| 948 | !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) | |
| 949 | ) | |
| 950 | return; | |
| 927 | 951 | setIsRetrying(true); |
| 928 | 952 | void retryTelemetry() |
| 929 | 953 | .catch((error: unknown) => { |
| ... | ... | @@ -1253,6 +1277,7 @@ export function ResourceTelemetryDiagnostics({ |
| 1253 | 1277 | <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-xs/5"> |
| 1254 | 1278 | <ProcessTable |
| 1255 | 1279 | processes={snapshot?.processes ?? []} |
| 1280 | canMaintainEnvironment={canMaintainEnvironment} | |
| 1256 | 1281 | signalingKeys={signalingKeys} |
| 1257 | 1282 | onSignal={signalProcess} |
| 1258 | 1283 | /> |
apps/web/src/components/settings/ScheduledTasksSettings.tsx+21-6| ... | ... | @@ -59,6 +59,8 @@ import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; |
| 59 | 59 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 60 | 60 | import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; |
| 61 | 61 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 62 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 63 | import { readEnvironmentScope } from "~/state/session"; | |
| 62 | 64 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 63 | 65 | import { |
| 64 | 66 | WEBHOOK_SIGNATURE_DEFAULTS, |
| ... | ... | @@ -246,6 +248,9 @@ export function ScheduledTasksSettings(target: { |
| 246 | 248 | setEditor({ environmentId, task }); |
| 247 | 249 | }, []); |
| 248 | 250 | const defaultEnvironment = environment ?? connectedEnvironments[0]; |
| 251 | const canCreate = useAtomValue( | |
| 252 | serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), | |
| 253 | ); | |
| 249 | 254 | return ( |
| 250 | 255 | <SettingsPageContainer> |
| 251 | 256 | <SettingsSection |
| ... | ... | @@ -255,7 +260,7 @@ export function ScheduledTasksSettings(target: { |
| 255 | 260 | <Button |
| 256 | 261 | size="xs" |
| 257 | 262 | variant="ghost-muted" |
| 258 | disabled={!defaultEnvironment} | |
| 263 | disabled={!defaultEnvironment || !canCreate} | |
| 259 | 264 | onClick={() => |
| 260 | 265 | defaultEnvironment && |
| 261 | 266 | setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) |
| ... | ... | @@ -404,6 +409,9 @@ function ScheduledTaskRow({ |
| 404 | 409 | readonly task: ScheduledTask; |
| 405 | 410 | readonly onEdit: () => void; |
| 406 | 411 | }) { |
| 412 | const canOperate = useAtomValue( | |
| 413 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), | |
| 414 | ); | |
| 407 | 415 | const [busy, setBusy] = useState(false); |
| 408 | 416 | const [deliveriesOpen, setDeliveriesOpen] = useState(false); |
| 409 | 417 | const isWebhook = task.schedule.type === "webhook"; |
| ... | ... | @@ -417,7 +425,7 @@ function ScheduledTaskRow({ |
| 417 | 425 | label: "scheduled task delete", |
| 418 | 426 | }); |
| 419 | 427 | const act = async (action: "toggle" | "run" | "delete") => { |
| 420 | if (busy) return; | |
| 428 | if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 421 | 429 | setBusy(true); |
| 422 | 430 | const result = |
| 423 | 431 | action === "toggle" |
| ... | ... | @@ -462,7 +470,7 @@ function ScheduledTaskRow({ |
| 462 | 470 | <div className="flex items-center gap-2"> |
| 463 | 471 | <Switch |
| 464 | 472 | checked={task.enabled} |
| 465 | disabled={busy} | |
| 473 | disabled={busy || !canOperate} | |
| 466 | 474 | aria-label={`Enable ${task.title}`} |
| 467 | 475 | onCheckedChange={() => void act("toggle")} |
| 468 | 476 | /> |
| ... | ... | @@ -472,7 +480,7 @@ function ScheduledTaskRow({ |
| 472 | 480 | <Button |
| 473 | 481 | size="icon-sm" |
| 474 | 482 | variant="ghost" |
| 475 | disabled={busy} | |
| 483 | disabled={busy || !canOperate} | |
| 476 | 484 | aria-label={`Actions for ${task.title}`} |
| 477 | 485 | /> |
| 478 | 486 | } |
| ... | ... | @@ -668,6 +676,9 @@ function WebhookEndpointField({ |
| 668 | 676 | readonly environmentId: EnvironmentId; |
| 669 | 677 | readonly task: ScheduledTask | null; |
| 670 | 678 | }) { |
| 679 | const canRotate = useAtomValue( | |
| 680 | serverEnvironment.rotateScheduledTaskWebhookToken.permissionAtom(environmentId), | |
| 681 | ); | |
| 671 | 682 | const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); |
| 672 | 683 | const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" }); |
| 673 | 684 | const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, { |
| ... | ... | @@ -725,7 +736,7 @@ function WebhookEndpointField({ |
| 725 | 736 | size="sm" |
| 726 | 737 | variant="outline" |
| 727 | 738 | type="button" |
| 728 | disabled={rotating} | |
| 739 | disabled={rotating || !canRotate} | |
| 729 | 740 | onClick={() => void rotateUrl()} |
| 730 | 741 | > |
| 731 | 742 | Rotate |
| ... | ... | @@ -787,6 +798,9 @@ function ScheduledTaskEditorDialog({ |
| 787 | 798 | const settings = useEnvironmentSettings(environmentId); |
| 788 | 799 | const providers = |
| 789 | 800 | useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; |
| 801 | const canOperate = useAtomValue( | |
| 802 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), | |
| 803 | ); | |
| 790 | 804 | const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { |
| 791 | 805 | label: "scheduled task upsert", |
| 792 | 806 | }); |
| ... | ... | @@ -840,6 +854,7 @@ function ScheduledTaskEditorDialog({ |
| 840 | 854 | |
| 841 | 855 | const submit = async () => { |
| 842 | 856 | if ( |
| 857 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || | |
| 843 | 858 | submissionPending.current || |
| 844 | 859 | saving || |
| 845 | 860 | editingTaskMissing || |
| ... | ... | @@ -1348,7 +1363,7 @@ function ScheduledTaskEditorDialog({ |
| 1348 | 1363 | </DialogClose> |
| 1349 | 1364 | <Button |
| 1350 | 1365 | size="sm" |
| 1351 | disabled={saving || editingTaskMissing || !connected || !tasksQuery.data} | |
| 1366 | disabled={!canOperate || saving || editingTaskMissing || !connected || !tasksQuery.data} | |
| 1352 | 1367 | onClick={() => void submit()} |
| 1353 | 1368 | > |
| 1354 | 1369 | {draft.editingId ? "Save task" : "Create task"} |
apps/web/src/components/settings/SettingsPanels.tsx+9-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; | |
| 2 | 3 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | 4 | import { NotificationSettings } from "./NotificationSettings"; |
| 4 | 5 | import { PRIVACY_POLICY_URL } from "../../legalLinks"; |
| ... | ... | @@ -876,6 +877,7 @@ function BackgroundActivityAdvancedDialog({ |
| 876 | 877 | readonly open: boolean; |
| 877 | 878 | readonly onOpenChange: (open: boolean) => void; |
| 878 | 879 | }) { |
| 880 | const canWriteSettings = useScopedSettingsWriteAllowed(); | |
| 879 | 881 | const settings = useScopedSettings(); |
| 880 | 882 | const updateSettings = useUpdateScopedSettings(); |
| 881 | 883 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); |
| ... | ... | @@ -894,7 +896,7 @@ function BackgroundActivityAdvancedDialog({ |
| 894 | 896 | ); |
| 895 | 897 | |
| 896 | 898 | return ( |
| 897 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 899 | <Dialog open={open && canWriteSettings} onOpenChange={onOpenChange}> | |
| 898 | 900 | <DialogPopup className="max-w-xl"> |
| 899 | 901 | <DialogHeader> |
| 900 | 902 | <DialogTitle>Background Activity</DialogTitle> |
| ... | ... | @@ -903,7 +905,10 @@ function BackgroundActivityAdvancedDialog({ |
| 903 | 905 | </DialogDescription> |
| 904 | 906 | </DialogHeader> |
| 905 | 907 | <DialogPanel> |
| 906 | <div className="overflow-hidden rounded-xl border bg-card text-card-foreground"> | |
| 908 | <fieldset | |
| 909 | disabled={!canWriteSettings} | |
| 910 | className="min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground" | |
| 911 | > | |
| 907 | 912 | <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> |
| 908 | 913 | <div className="min-w-0 space-y-1"> |
| 909 | 914 | <div className="text-sm font-medium">Shared policy</div> |
| ... | ... | @@ -1125,11 +1130,12 @@ function BackgroundActivityAdvancedDialog({ |
| 1125 | 1130 | </label> |
| 1126 | 1131 | ))} |
| 1127 | 1132 | </div> |
| 1128 | </div> | |
| 1133 | </fieldset> | |
| 1129 | 1134 | </DialogPanel> |
| 1130 | 1135 | <DialogFooter> |
| 1131 | 1136 | <Button |
| 1132 | 1137 | variant="outline" |
| 1138 | disabled={!canWriteSettings} | |
| 1133 | 1139 | onClick={() => updateSettings(resetBackgroundActivitySettings())} |
| 1134 | 1140 | > |
| 1135 | 1141 | Reset all |
apps/web/src/components/settings/SnapShotSettings.test.tsx+4| ... | ... | @@ -25,6 +25,10 @@ vi.mock("react/compiler-runtime", async () => { |
| 25 | 25 | return { c: reactHookHarness.useMemoCache }; |
| 26 | 26 | }); |
| 27 | 27 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 28 | vi.mock("../../state/environments", () => ({ | |
| 29 | usePrimaryEnvironmentId: () => null, | |
| 30 | useEnvironments: () => [], | |
| 31 | })); | |
| 28 | 32 | vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); |
| 29 | 33 | vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null })); |
| 30 | 34 | vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); |
apps/web/src/components/settings/settingsLayout.tsx+27-5| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 3 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 4 | import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; | |
| 2 | 5 | import { InfoIcon, Undo2Icon } from "lucide-react"; |
| 3 | 6 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; |
| 4 | 7 | import * as Equal from "effect/Equal"; |
| ... | ... | @@ -27,6 +30,7 @@ import { SettingsScopeSentence } from "./SettingsScopeSentence"; |
| 27 | 30 | import { |
| 28 | 31 | isProjectScopedSettingKey, |
| 29 | 32 | listProjectOverrides, |
| 33 | type ProjectOverrideEntry, | |
| 30 | 34 | scopedSettingsAreMixed, |
| 31 | 35 | scopedSettingsSource, |
| 32 | 36 | } from "./scopedSettings"; |
| ... | ... | @@ -281,6 +285,16 @@ export function SettingsRow({ |
| 281 | 285 | const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id); |
| 282 | 286 | const primarySettingsAvailable = usePrimarySettingsAvailable(); |
| 283 | 287 | const context = useOptionalSettingsScope(); |
| 288 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 289 | const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope); | |
| 290 | const writableIds = useEnvironmentsWithScope( | |
| 291 | context?.connectedEnvironments ?? [], | |
| 292 | AuthSettingsWriteScope, | |
| 293 | ); | |
| 294 | const canWriteSettings = context | |
| 295 | ? context.connectedEnvironments.length > 0 && | |
| 296 | context.connectedEnvironments.every((target) => writableIds.has(target.environmentId)) | |
| 297 | : primaryCanWrite; | |
| 284 | 298 | const clearOverrides = useClearScopedSettings(); |
| 285 | 299 | const clearProjectOverrides = useClearProjectOverrides(); |
| 286 | 300 | const isProjectScope = |
| ... | ... | @@ -295,7 +309,8 @@ export function SettingsRow({ |
| 295 | 309 | context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null; |
| 296 | 310 | const unavailable = |
| 297 | 311 | serverScoped && |
| 298 | !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable); | |
| 312 | (!canWriteSettings || | |
| 313 | !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable)); | |
| 299 | 314 | const inheritedFrom = |
| 300 | 315 | source === "environment" && context?.scope.environmentIds.length === 1 |
| 301 | 316 | ? (context.environments.find( |
| ... | ... | @@ -375,9 +390,11 @@ export function SettingsRow({ |
| 375 | 390 | const renderedControl = |
| 376 | 391 | unavailable && control |
| 377 | 392 | ? inertControl( |
| 378 | context | |
| 379 | ? "Reconnect the selected environment to change this setting." | |
| 380 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, | |
| 393 | !canWriteSettings | |
| 394 | ? "This connection does not have permission to change environment settings." | |
| 395 | : context | |
| 396 | ? "Reconnect the selected environment to change this setting." | |
| 397 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, | |
| 381 | 398 | ) |
| 382 | 399 | : environmentWide && control |
| 383 | 400 | ? inertControl("Environment-wide setting. Select an environment to change it.") |
| ... | ... | @@ -415,7 +432,12 @@ export function SettingsRow({ |
| 415 | 432 | environments={context.connectedEnvironments} |
| 416 | 433 | keys={settingKeys} |
| 417 | 434 | overridingProjects={overridingProjects} |
| 418 | onClearOverrides={(entries) => clearProjectOverrides(entries, scopedKeys)} | |
| 435 | {...(canWriteSettings | |
| 436 | ? { | |
| 437 | onClearOverrides: (entries: readonly ProjectOverrideEntry[]) => | |
| 438 | clearProjectOverrides(entries, scopedKeys), | |
| 439 | } | |
| 440 | : {})} | |
| 419 | 441 | /> |
| 420 | 442 | ) : null; |
| 421 | 443 | const renderedStatus = status; |
apps/web/src/components/settings/settingsSearch.ts-1| ... | ... | @@ -813,7 +813,6 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 813 | 813 | to: "/settings/connections", |
| 814 | 814 | targetId: "connections-environment", |
| 815 | 815 | searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], |
| 816 | localBackendManagementOnly: true, | |
| 817 | 816 | }, |
| 818 | 817 | { |
| 819 | 818 | id: "local-environment", |
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+7-11| ... | ... | @@ -1,14 +1,14 @@ |
| 1 | 1 | import { useMemo } from "react"; |
| 2 | import { AuthAccessWriteScope } from "@t3tools/contracts"; | |
| 2 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; |
| 5 | 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 6 | 6 | import { isElectron } from "~/env"; |
| 7 | 7 | import { isLocalEnvironmentDisabled } from "~/localEnvironment"; |
| 8 | 8 | import { desktopWslStateAtom } from "~/state/desktopWslState"; |
| 9 | import { useEnvironments } from "~/state/environments"; | |
| 9 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | |
| 10 | 10 | import { useEnvironmentQuery } from "~/state/query"; |
| 11 | import { usePrimarySessionState } from "~/environments/primary"; | |
| 11 | import { useEnvironmentScope } from "~/state/session"; | |
| 12 | 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 13 | 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 14 | 14 | import type { SettingsScopeSearch } from "./settingsScope"; |
| ... | ... | @@ -19,21 +19,17 @@ import { |
| 19 | 19 | |
| 20 | 20 | export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) { |
| 21 | 21 | const { environments } = useEnvironments(); |
| 22 | const primarySessionState = usePrimarySessionState(); | |
| 22 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 23 | 23 | const localEnvironmentDisabled = isLocalEnvironmentDisabled(); |
| 24 | const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); | |
| 25 | const canManageLocalBackend = !localEnvironmentDisabled && canMaintain; | |
| 24 | 26 | const desktopWsl = useEnvironmentQuery( |
| 25 | isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null, | |
| 27 | isElectron && canManageLocalBackend ? desktopWslStateAtom : null, | |
| 26 | 28 | ); |
| 27 | 29 | const cloudLinkState = usePrimaryCloudLinkState().data; |
| 28 | 30 | // Same fallback as the Connections row: older servers imply a tunnel from `linked`. |
| 29 | 31 | const managedTunnelActive = |
| 30 | 32 | cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false; |
| 31 | const canManageLocalBackend = | |
| 32 | !localEnvironmentDisabled && | |
| 33 | (isElectron || | |
| 34 | ((primarySessionState.data?.authenticated && | |
| 35 | primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? | |
| 36 | false)); | |
| 37 | 33 | |
| 38 | 34 | return useMemo( |
| 39 | 35 | () => |
apps/web/src/components/settings/useHostConnectionChecks.ts+5| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { useRef, useState } from "react"; |
| 2 | 4 | import * as Cause from "effect/Cause"; |
| 3 | 5 | import type { SshDeviceHostConfig } from "@t3tools/contracts"; |
| ... | ... | @@ -25,6 +27,9 @@ export function useHostConnectionChecks(targets: ReadonlyArray<DeviceHostCheckTa |
| 25 | 27 | targets, |
| 26 | 28 | host, |
| 27 | 29 | async (environmentId, input) => { |
| 30 | if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { | |
| 31 | throw new Error("This connection cannot test device hosts."); | |
| 32 | } | |
| 28 | 33 | const result = await test({ environmentId, input }); |
| 29 | 34 | if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause)); |
| 30 | 35 | return result.value; |
apps/web/src/components/settings/useProjectScriptSettings.ts+18| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { AuthSettingsWriteScope, EnvironmentAuthorizationError } from "@t3tools/contracts"; | |
| 2 | import { readEnvironmentScope } from "../../state/session"; | |
| 1 | 3 | import { |
| 2 | 4 | isAtomCommandInterrupted, |
| 3 | 5 | mapAtomCommandResult, |
| ... | ... | @@ -80,6 +82,22 @@ export function useProjectScriptSettings( |
| 80 | 82 | toastManager.add({ type: "error", title: "Actions not saved", description: message }); |
| 81 | 83 | return AsyncResult.failure(Cause.fail(new Error(message))); |
| 82 | 84 | } |
| 85 | if ( | |
| 86 | targets.some( | |
| 87 | ({ environmentId }) => !readEnvironmentScope(environmentId, AuthSettingsWriteScope), | |
| 88 | ) | |
| 89 | ) { | |
| 90 | return reportScriptFailure( | |
| 91 | AsyncResult.failure( | |
| 92 | Cause.fail( | |
| 93 | new EnvironmentAuthorizationError({ | |
| 94 | requiredScope: AuthSettingsWriteScope, | |
| 95 | message: "This connection cannot change environment settings.", | |
| 96 | }), | |
| 97 | ), | |
| 98 | ), | |
| 99 | ); | |
| 100 | } | |
| 83 | 101 | savingRef.current = true; |
| 84 | 102 | setSaving(true); |
| 85 | 103 | try { |
apps/web/src/components/settings/useScopedSettings.ts+44-12| ... | ... | @@ -1,3 +1,12 @@ |
| 1 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | |
| 2 | import { useEnvironmentsWithScope } from "../../state/session"; | |
| 3 | import { | |
| 4 | requiredScopesForServerSettingsPatch, | |
| 5 | EnvironmentAuthorizationError, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { readEnvironmentScope } from "../../state/session"; | |
| 8 | import * as Cause from "effect/Cause"; | |
| 9 | import { AsyncResult } from "effect/reactivity"; | |
| 1 | 10 | import { |
| 2 | 11 | DEFAULT_SERVER_SETTINGS, |
| 3 | 12 | type ProjectScopedServerSettingKey, |
| ... | ... | @@ -63,19 +72,33 @@ function useRunScopedPlan() { |
| 63 | 72 | }); |
| 64 | 73 | return; |
| 65 | 74 | } |
| 66 | void persistScopedSettingsPatch(plan, persistServer, persistClientSettingsPatch).then( | |
| 67 | ({ failedEnvironments, savedEnvironmentCount }) => { | |
| 68 | if (failedEnvironments.length === 0) return; | |
| 69 | toastManager.add({ | |
| 70 | type: "error", | |
| 71 | title: | |
| 72 | savedEnvironmentCount > 0 | |
| 73 | ? "Setting saved on some environments" | |
| 74 | : "Setting not saved", | |
| 75 | description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`, | |
| 76 | }); | |
| 75 | void persistScopedSettingsPatch( | |
| 76 | plan, | |
| 77 | async (request) => { | |
| 78 | const missing = requiredScopesForServerSettingsPatch(request.input.patch).find( | |
| 79 | (scope) => !readEnvironmentScope(request.environmentId, scope), | |
| 80 | ); | |
| 81 | if (missing) | |
| 82 | return AsyncResult.failure( | |
| 83 | Cause.fail( | |
| 84 | new EnvironmentAuthorizationError({ | |
| 85 | requiredScope: missing, | |
| 86 | message: "This connection cannot change these settings.", | |
| 87 | }), | |
| 88 | ), | |
| 89 | ); | |
| 90 | return persistServer(request); | |
| 77 | 91 | }, |
| 78 | ); | |
| 92 | persistClientSettingsPatch, | |
| 93 | ).then(({ failedEnvironments, savedEnvironmentCount }) => { | |
| 94 | if (failedEnvironments.length === 0) return; | |
| 95 | toastManager.add({ | |
| 96 | type: "error", | |
| 97 | title: | |
| 98 | savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved", | |
| 99 | description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`, | |
| 100 | }); | |
| 101 | }); | |
| 79 | 102 | }, |
| 80 | 103 | [persistServer], |
| 81 | 104 | ); |
| ... | ... | @@ -119,3 +142,12 @@ export function useClearProjectOverrides() { |
| 119 | 142 | [context, run], |
| 120 | 143 | ); |
| 121 | 144 | } |
| 145 | ||
| 146 | export function useScopedSettingsWriteAllowed() { | |
| 147 | const { connectedEnvironments } = useSettingsScope(); | |
| 148 | const writable = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope); | |
| 149 | return ( | |
| 150 | connectedEnvironments.length > 0 && | |
| 151 | connectedEnvironments.every((target) => writable.has(target.environmentId)) | |
| 152 | ); | |
| 153 | } |
apps/web/src/components/usage/UsageLimits.tsx+23-4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | AuthProvidersManageScope, | |
| 2 | 3 | type EnvironmentId, |
| 3 | 4 | type ProviderConsumeResetCreditOutcome, |
| 4 | 5 | ProviderConsumeResetCreditInput, |
| ... | ... | @@ -21,6 +22,7 @@ import { Fragment, type ReactNode, useState } from "react"; |
| 21 | 22 | |
| 22 | 23 | import { usePrimarySettings } from "../../hooks/useSettings"; |
| 23 | 24 | import { environmentPresentations } from "../../state/presentation"; |
| 25 | import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; | |
| 24 | 26 | import { serverEnvironment } from "../../state/server"; |
| 25 | 27 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 26 | 28 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| ... | ... | @@ -206,6 +208,7 @@ export function useResetCredit( |
| 206 | 208 | environmentId: EnvironmentId, |
| 207 | 209 | input: ProviderConsumeResetCreditInput, |
| 208 | 210 | ) { |
| 211 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); | |
| 209 | 212 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); |
| 210 | 213 | const [confirming, setConfirming] = useState(false); |
| 211 | 214 | const [busy, setBusy] = useState(false); |
| ... | ... | @@ -213,6 +216,7 @@ export function useResetCredit( |
| 213 | 216 | |
| 214 | 217 | const redeem = async () => { |
| 215 | 218 | setConfirming(false); |
| 219 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; | |
| 216 | 220 | setBusy(true); |
| 217 | 221 | setStatus(null); |
| 218 | 222 | const result = await consume({ environmentId, input }); |
| ... | ... | @@ -228,7 +232,7 @@ export function useResetCredit( |
| 228 | 232 | ); |
| 229 | 233 | }; |
| 230 | 234 | |
| 231 | return { confirming, setConfirming, busy, status, redeem }; | |
| 235 | return { canManageProviders, confirming, setConfirming, busy, status, redeem }; | |
| 232 | 236 | } |
| 233 | 237 | |
| 234 | 238 | /** |
| ... | ... | @@ -241,10 +245,12 @@ export function ResetCreditDialog({ |
| 241 | 245 | open, |
| 242 | 246 | onOpenChange, |
| 243 | 247 | onConfirm, |
| 248 | disabled = false, | |
| 244 | 249 | }: { |
| 245 | 250 | readonly open: boolean; |
| 246 | 251 | readonly onOpenChange: (open: boolean) => void; |
| 247 | 252 | readonly onConfirm: () => void; |
| 253 | readonly disabled?: boolean; | |
| 248 | 254 | }) { |
| 249 | 255 | return ( |
| 250 | 256 | <AlertDialog open={open} onOpenChange={onOpenChange}> |
| ... | ... | @@ -258,7 +264,9 @@ export function ResetCreditDialog({ |
| 258 | 264 | </AlertDialogHeader> |
| 259 | 265 | <AlertDialogFooter> |
| 260 | 266 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 261 | <Button onClick={onConfirm}>Use credit</Button> | |
| 267 | <Button disabled={disabled} onClick={onConfirm}> | |
| 268 | Use credit | |
| 269 | </Button> | |
| 262 | 270 | </AlertDialogFooter> |
| 263 | 271 | </AlertDialogPopup> |
| 264 | 272 | </AlertDialog> |
| ... | ... | @@ -294,13 +302,23 @@ export function ResetCredits({ |
| 294 | 302 | readonly credits: ServerProviderResetCredits; |
| 295 | 303 | readonly now: number; |
| 296 | 304 | }) { |
| 297 | const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input); | |
| 305 | const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit( | |
| 306 | environmentId, | |
| 307 | input, | |
| 308 | ); | |
| 298 | 309 | if (credits.availableCount === 0 && status === null) return null; |
| 299 | 310 | return ( |
| 300 | 311 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> |
| 301 | 312 | <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span> |
| 302 | 313 | {credits.availableCount > 0 ? ( |
| 303 | <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}> | |
| 314 | <Button | |
| 315 | size="xs" | |
| 316 | variant="outline" | |
| 317 | disabled={busy || !canManageProviders} | |
| 318 | onClick={() => { | |
| 319 | if (readEnvironmentScope(environmentId, AuthProvidersManageScope)) setConfirming(true); | |
| 320 | }} | |
| 321 | > | |
| 304 | 322 | {busy ? "Using…" : "Use reset"} |
| 305 | 323 | </Button> |
| 306 | 324 | ) : null} |
| ... | ... | @@ -309,6 +327,7 @@ export function ResetCredits({ |
| 309 | 327 | open={confirming} |
| 310 | 328 | onOpenChange={setConfirming} |
| 311 | 329 | onConfirm={() => void redeem()} |
| 330 | disabled={!canManageProviders} | |
| 312 | 331 | /> |
| 313 | 332 | </div> |
| 314 | 333 | ); |
apps/web/src/components/usage/UsageLimitsPooled.tsx+2-1| ... | ... | @@ -204,7 +204,7 @@ function SegmentPopover({ |
| 204 | 204 | <Button |
| 205 | 205 | size="xs" |
| 206 | 206 | variant="outline" |
| 207 | disabled={redeem.busy} | |
| 207 | disabled={redeem.busy || !redeem.canManageProviders} | |
| 208 | 208 | className="ms-auto" |
| 209 | 209 | onClick={onRedeem} |
| 210 | 210 | > |
| ... | ... | @@ -429,6 +429,7 @@ function RedeemableSegmentPopup({ |
| 429 | 429 | open={redeem.confirming} |
| 430 | 430 | onOpenChange={redeem.setConfirming} |
| 431 | 431 | onConfirm={() => void redeem.redeem()} |
| 432 | disabled={!redeem.canManageProviders} | |
| 432 | 433 | /> |
| 433 | 434 | {/* The popover closed before the confirm, so the outcome needs a home outside it. */} |
| 434 | 435 | {redeem.status ? ( |
apps/web/src/components/usage/UsagePriceOverrides.tsx+17-17| ... | ... | @@ -1,21 +1,16 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 3 | import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 6 | 6 | import { useRef, useState } from "react"; |
| 7 | 7 | |
| 8 | import { isElectron } from "../../env"; | |
| 9 | 8 | import { cn } from "../../lib/utils"; |
| 10 | 9 | import { environmentPresentations } from "../../state/presentation"; |
| 11 | 10 | import { serverEnvironment } from "../../state/server"; |
| 12 | import { environmentSession } from "../../state/session"; | |
| 11 | import { environmentSession, readEnvironmentScope } from "../../state/session"; | |
| 13 | 12 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 14 | 13 | import type { EnvironmentUsageStatus } from "../../state/usage"; |
| 15 | import { | |
| 16 | resolvePrimaryOperateAccess, | |
| 17 | resolveRemoteOperateAccess, | |
| 18 | } from "../settings/ProviderSettingsPanel.logic"; | |
| 19 | 14 | import { Button } from "../ui/button"; |
| 20 | 15 | import { |
| 21 | 16 | Dialog, |
| ... | ... | @@ -51,15 +46,17 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => |
| 51 | 46 | [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => { |
| 52 | 47 | const settings = get(serverEnvironment.settingsValueAtom(environmentId)); |
| 53 | 48 | const session = get(environmentSession.sessionStateAtom(environmentId)); |
| 54 | const sessionAccess = { | |
| 55 | session: Option.getOrNull(AsyncResult.value(session)), | |
| 56 | isPending: session.waiting, | |
| 57 | hasError: session._tag === "Failure", | |
| 58 | }; | |
| 59 | const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; | |
| 60 | const access = isPrimary | |
| 61 | ? resolvePrimaryOperateAccess({ ...sessionAccess, isPrimary, hasDesktopBridge: isElectron }) | |
| 62 | : resolveRemoteOperateAccess(sessionAccess); | |
| 49 | const sessionData = Option.getOrNull(AsyncResult.value(session)); | |
| 50 | const access = | |
| 51 | session._tag === "Failure" | |
| 52 | ? "denied" | |
| 53 | : sessionData === null | |
| 54 | ? session.waiting | |
| 55 | ? "pending" | |
| 56 | : "denied" | |
| 57 | : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope) | |
| 58 | ? "granted" | |
| 59 | : "denied"; | |
| 63 | 60 | return { |
| 64 | 61 | environmentId, |
| 65 | 62 | label: environment.entry.target.label, |
| ... | ... | @@ -262,7 +259,10 @@ export function UsagePriceOverrides({ |
| 262 | 259 | target.unavailable ?? [...changes.get(target.environmentId)!.errors.values()][0] ?? null, |
| 263 | 260 | })), |
| 264 | 261 | changes: new Map([...changes].map(([id, plan]) => [id, plan.changes])), |
| 265 | write: updateSettings, | |
| 262 | write: async (input) => | |
| 263 | readEnvironmentScope(input.environmentId, AuthSettingsWriteScope) | |
| 264 | ? updateSettings(input) | |
| 265 | : { _tag: "Failure" }, | |
| 266 | 266 | onResult: (environmentId, result) => { |
| 267 | 267 | if (result.status === "failed") failed = true; |
| 268 | 268 | setAttempt((previous) => |
apps/web/src/editorPreferences.test.ts created+88| ... | ... | @@ -0,0 +1,88 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | EnvironmentAuthorizationError, | |
| 4 | EnvironmentId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Cause from "effect/Cause"; | |
| 7 | import { AsyncResult } from "effect/reactivity"; | |
| 8 | import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | const state = vi.hoisted(() => ({ | |
| 11 | allowed: new Set<string>(), | |
| 12 | run: vi.fn(), | |
| 13 | getPreference: vi.fn(), | |
| 14 | setPreference: vi.fn(), | |
| 15 | })); | |
| 16 | ||
| 17 | vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); | |
| 18 | vi.mock("./state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } })); | |
| 19 | vi.mock("./state/use-atom-command", () => ({ useAtomCommand: () => state.run })); | |
| 20 | vi.mock("./state/session", () => ({ | |
| 21 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 22 | scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId), | |
| 23 | })); | |
| 24 | vi.mock("./hooks/useLocalStorage", () => ({ | |
| 25 | getLocalStorageItem: state.getPreference, | |
| 26 | setLocalStorageItem: state.setPreference, | |
| 27 | useLocalStorage: vi.fn(), | |
| 28 | })); | |
| 29 | ||
| 30 | import { useOpenInPreferredEditor } from "./editorPreferences"; | |
| 31 | ||
| 32 | const primary = EnvironmentId.make("primary"); | |
| 33 | const secondary = EnvironmentId.make("secondary"); | |
| 34 | ||
| 35 | beforeEach(() => { | |
| 36 | state.allowed.clear(); | |
| 37 | state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); | |
| 38 | state.getPreference.mockReset().mockReturnValue(null); | |
| 39 | state.setPreference.mockReset(); | |
| 40 | }); | |
| 41 | ||
| 42 | it("does not use another environment's grant or change preferences when denied", async () => { | |
| 43 | state.allowed.add(primary); | |
| 44 | const open = useOpenInPreferredEditor(secondary, ["vscode"]); | |
| 45 | const result = await open("/work/readme.md"); | |
| 46 | assert(result._tag === "Failure"); | |
| 47 | const error = Cause.squash(result.cause); | |
| 48 | expect(error).toBeInstanceOf(EnvironmentAuthorizationError); | |
| 49 | expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); | |
| 50 | expect(state.run).not.toHaveBeenCalled(); | |
| 51 | expect(state.getPreference).not.toHaveBeenCalled(); | |
| 52 | expect(state.setPreference).not.toHaveBeenCalled(); | |
| 53 | }); | |
| 54 | ||
| 55 | it("checks revocation and regrant when a retained editor callback is invoked", async () => { | |
| 56 | state.allowed.add(secondary); | |
| 57 | const open = useOpenInPreferredEditor(secondary, ["vscode"]); | |
| 58 | state.allowed.delete(secondary); | |
| 59 | expect((await open("/work/readme.md:5"))._tag).toBe("Failure"); | |
| 60 | expect(state.run).not.toHaveBeenCalled(); | |
| 61 | ||
| 62 | state.allowed.add(secondary); | |
| 63 | expect(await open("/work/readme.md:5")).toMatchObject({ _tag: "Success", value: "vscode" }); | |
| 64 | expect(state.run).toHaveBeenCalledExactlyOnceWith({ | |
| 65 | environmentId: secondary, | |
| 66 | input: { cwd: "/work/readme.md:5", editor: "vscode" }, | |
| 67 | }); | |
| 68 | expect(state.setPreference).toHaveBeenCalledTimes(1); | |
| 69 | }); | |
| 70 | ||
| 71 | it("keeps the stored available editor for an authorized launch", async () => { | |
| 72 | state.allowed.add(secondary); | |
| 73 | state.getPreference.mockReturnValue("cursor"); | |
| 74 | const open = useOpenInPreferredEditor(secondary, ["vscode", "cursor"]); | |
| 75 | expect(await open("/work")).toMatchObject({ _tag: "Success", value: "cursor" }); | |
| 76 | expect(state.run).toHaveBeenCalledExactlyOnceWith({ | |
| 77 | environmentId: secondary, | |
| 78 | input: { cwd: "/work", editor: "cursor" }, | |
| 79 | }); | |
| 80 | expect(state.setPreference).not.toHaveBeenCalled(); | |
| 81 | }); | |
| 82 | ||
| 83 | it("returns the existing failures for a missing environment or editor", async () => { | |
| 84 | state.allowed.add(secondary); | |
| 85 | expect((await useOpenInPreferredEditor(null, ["vscode"])("/work"))._tag).toBe("Failure"); | |
| 86 | expect((await useOpenInPreferredEditor(secondary, [])("/work"))._tag).toBe("Failure"); | |
| 87 | expect(state.run).not.toHaveBeenCalled(); | |
| 88 | }); |
apps/web/src/editorPreferences.ts+20-4| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | EDITORS, | |
| 4 | EditorId, | |
| 5 | EnvironmentAuthorizationError, | |
| 6 | EnvironmentId, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import { |
| 3 | 9 | mapAtomCommandResult, |
| 4 | 10 | type AtomCommandFailure, |
| ... | ... | @@ -11,6 +17,7 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hoo |
| 11 | 17 | import { useCallback, useMemo } from "react"; |
| 12 | 18 | import { shellEnvironment } from "./state/shell"; |
| 13 | 19 | import { useAtomCommand } from "./state/use-atom-command"; |
| 20 | import { readEnvironmentScope } from "./state/session"; | |
| 14 | 21 | |
| 15 | 22 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 16 | 23 | |
| ... | ... | @@ -49,9 +56,7 @@ export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) { |
| 49 | 56 | return [effectiveEditor, setLastEditor] as const; |
| 50 | 57 | } |
| 51 | 58 | |
| 52 | export function resolveAndPersistPreferredEditor( | |
| 53 | availableEditors: readonly EditorId[], | |
| 54 | ): EditorId | null { | |
| 59 | function resolveAndPersistPreferredEditor(availableEditors: readonly EditorId[]): EditorId | null { | |
| 55 | 60 | const availableEditorIds = new Set(availableEditors); |
| 56 | 61 | const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); |
| 57 | 62 | if (stored && availableEditorIds.has(stored)) return stored; |
| ... | ... | @@ -78,6 +83,7 @@ export function useOpenInPreferredEditor( |
| 78 | 83 | | OpenInEditorError |
| 79 | 84 | | PreferredEditorEnvironmentRequiredError |
| 80 | 85 | | PreferredEditorUnavailableError |
| 86 | | EnvironmentAuthorizationError | |
| 81 | 87 | > |
| 82 | 88 | > => { |
| 83 | 89 | if (environmentId === null) { |
| ... | ... | @@ -89,6 +95,16 @@ export function useOpenInPreferredEditor( |
| 89 | 95 | ), |
| 90 | 96 | ); |
| 91 | 97 | } |
| 98 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { | |
| 99 | return AsyncResult.failure( | |
| 100 | Cause.fail( | |
| 101 | new EnvironmentAuthorizationError({ | |
| 102 | requiredScope: AuthOrchestrationOperateScope, | |
| 103 | message: "This connection cannot open an editor on this environment.", | |
| 104 | }), | |
| 105 | ), | |
| 106 | ); | |
| 107 | } | |
| 92 | 108 | const editor = resolveAndPersistPreferredEditor(availableEditors); |
| 93 | 109 | if (!editor) { |
| 94 | 110 | return AsyncResult.failure( |
apps/web/src/hooks/useSettings.sync.test.tsx created+201| ... | ... | @@ -0,0 +1,201 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import { | |
| 3 | AuthSettingsWriteScope, | |
| 4 | DEFAULT_SERVER_SETTINGS, | |
| 5 | EnvironmentId, | |
| 6 | type AuthEnvironmentScope, | |
| 7 | type AuthSessionState, | |
| 8 | type ServerSettingsPatch, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Cause from "effect/Cause"; | |
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 12 | import { act } from "react"; | |
| 13 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 15 | ||
| 16 | type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>; | |
| 17 | const state = vi.hoisted(() => ({ | |
| 18 | registry: null as AtomRegistry.AtomRegistry | null, | |
| 19 | sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(), | |
| 20 | environments: [] as Array<{ | |
| 21 | environmentId: EnvironmentId; | |
| 22 | label: string; | |
| 23 | connection: { phase: "connected" | "disconnected" }; | |
| 24 | serverConfig: { | |
| 25 | environment: { capabilities: { threadAutoSettlement: boolean } }; | |
| 26 | settings: typeof DEFAULT_SERVER_SETTINGS; | |
| 27 | }; | |
| 28 | }>, | |
| 29 | persist: vi.fn(), | |
| 30 | toast: vi.fn(), | |
| 31 | })); | |
| 32 | ||
| 33 | vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined })); | |
| 34 | vi.mock("@t3tools/client-runtime/state/session", () => ({ | |
| 35 | createEnvironmentSessionAtoms: () => ({ | |
| 36 | sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, | |
| 37 | }), | |
| 38 | })); | |
| 39 | vi.mock("~/rpc/atomRegistry", () => ({ | |
| 40 | get appAtomRegistry() { | |
| 41 | return state.registry; | |
| 42 | }, | |
| 43 | })); | |
| 44 | vi.mock("~/state/environments", () => ({ | |
| 45 | useEnvironments: () => ({ environments: state.environments }), | |
| 46 | usePrimaryEnvironment: () => | |
| 47 | state.environments.find((environment) => environment.environmentId === primaryId) ?? null, | |
| 48 | })); | |
| 49 | vi.mock("~/state/server", () => ({ | |
| 50 | serverEnvironment: { updateSettings: Symbol("updateSettings") }, | |
| 51 | primaryServerSettingsAtom: undefined, | |
| 52 | })); | |
| 53 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); | |
| 54 | vi.mock("~/components/ui/toast", () => ({ toastManager: { add: state.toast } })); | |
| 55 | vi.mock("~/themePalette", () => ({})); | |
| 56 | vi.mock("./useTheme", () => ({})); | |
| 57 | ||
| 58 | import { useUpdatePrimarySettings } from "./useSettings"; | |
| 59 | ||
| 60 | const primaryId = EnvironmentId.make("primary"); | |
| 61 | const remoteId = EnvironmentId.make("remote"); | |
| 62 | const patch = { sidebarAutoSettleOnMerge: false } satisfies ServerSettingsPatch; | |
| 63 | const session = (scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState => ({ | |
| 64 | authenticated: true, | |
| 65 | scopes, | |
| 66 | auth: { | |
| 67 | policy: "remote-reachable", | |
| 68 | bootstrapMethods: ["one-time-token"], | |
| 69 | sessionMethods: ["bearer-access-token"], | |
| 70 | sessionCookieName: "t3_session", | |
| 71 | }, | |
| 72 | }); | |
| 73 | let renderer: ReactTestRenderer | undefined; | |
| 74 | ||
| 75 | function SettingsEditor() { | |
| 76 | const updateSettings = useUpdatePrimarySettings(); | |
| 77 | return <button onClick={() => updateSettings(patch)}>Save shared setting</button>; | |
| 78 | } | |
| 79 | ||
| 80 | function saveSharedSettings() { | |
| 81 | renderer!.root.findByType("button").props.onClick(); | |
| 82 | } | |
| 83 | ||
| 84 | async function mountEditor() { | |
| 85 | await act(() => { | |
| 86 | renderer = create( | |
| 87 | <RegistryContext.Provider value={state.registry!}> | |
| 88 | <SettingsEditor /> | |
| 89 | </RegistryContext.Provider>, | |
| 90 | ); | |
| 91 | }); | |
| 92 | } | |
| 93 | ||
| 94 | beforeEach(() => { | |
| 95 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 96 | state.registry = AtomRegistry.make(); | |
| 97 | state.sessions.clear(); | |
| 98 | state.sessions.set( | |
| 99 | primaryId, | |
| 100 | Atom.make<SessionResult>(AsyncResult.success(session([AuthSettingsWriteScope]))), | |
| 101 | ); | |
| 102 | state.sessions.set(remoteId, Atom.make<SessionResult>(AsyncResult.initial())); | |
| 103 | state.environments = [primaryId, remoteId].map((environmentId) => ({ | |
| 104 | environmentId, | |
| 105 | label: environmentId, | |
| 106 | connection: { phase: "connected" }, | |
| 107 | serverConfig: { | |
| 108 | environment: { capabilities: { threadAutoSettlement: true } }, | |
| 109 | settings: DEFAULT_SERVER_SETTINGS, | |
| 110 | }, | |
| 111 | })); | |
| 112 | state.persist.mockReset(); | |
| 113 | state.persist.mockResolvedValue(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); | |
| 114 | state.toast.mockReset(); | |
| 115 | }); | |
| 116 | ||
| 117 | afterEach(async () => { | |
| 118 | await act(() => renderer?.unmount()); | |
| 119 | renderer = undefined; | |
| 120 | state.registry?.dispose(); | |
| 121 | vi.unstubAllGlobals(); | |
| 122 | }); | |
| 123 | ||
| 124 | describe("shared settings writes", () => { | |
| 125 | it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { | |
| 126 | await mountEditor(); | |
| 127 | saveSharedSettings(); | |
| 128 | ||
| 129 | expect(state.persist.mock.calls).toEqual([ | |
| 130 | [{ environmentId: primaryId, input: { patch } }], | |
| 131 | [{ environmentId: remoteId, input: { patch } }], | |
| 132 | ]); | |
| 133 | expect(state.registry!.get(state.sessions.get(remoteId)!)).toMatchObject({ _tag: "Initial" }); | |
| 134 | }); | |
| 135 | ||
| 136 | it.each([ | |
| 137 | ["denied", () => AsyncResult.success(session([]))], | |
| 138 | ["denied while refreshing", () => AsyncResult.waiting(AsyncResult.success(session([])))], | |
| 139 | [ | |
| 140 | "failed", | |
| 141 | () => AsyncResult.failure<AuthSessionState, Error>(Cause.fail(new Error("session rejected"))), | |
| 142 | ], | |
| 143 | ] as const)("skips a remote whose grant is %s", async (_label, result) => { | |
| 144 | state.registry!.set(state.sessions.get(remoteId)!, result()); | |
| 145 | await mountEditor(); | |
| 146 | saveSharedSettings(); | |
| 147 | ||
| 148 | expect(state.persist).toHaveBeenCalledExactlyOnceWith({ | |
| 149 | environmentId: primaryId, | |
| 150 | input: { patch }, | |
| 151 | }); | |
| 152 | }); | |
| 153 | ||
| 154 | it.each(["disconnected", "unsupported"] as const)( | |
| 155 | "skips a %s remote even with a cold grant", | |
| 156 | async (condition) => { | |
| 157 | const remote = state.environments[1]!; | |
| 158 | if (condition === "disconnected") remote.connection.phase = "disconnected"; | |
| 159 | else remote.serverConfig.environment.capabilities.threadAutoSettlement = false; | |
| 160 | await mountEditor(); | |
| 161 | saveSharedSettings(); | |
| 162 | ||
| 163 | expect(state.persist).toHaveBeenCalledExactlyOnceWith({ | |
| 164 | environmentId: primaryId, | |
| 165 | input: { patch }, | |
| 166 | }); | |
| 167 | }, | |
| 168 | ); | |
| 169 | ||
| 170 | it.each(["primary", "hosted"] as const)( | |
| 171 | "rechecks a cold remote grant that resolves to denied after the %s handler renders", | |
| 172 | async (mode) => { | |
| 173 | if (mode === "hosted") { | |
| 174 | state.environments = state.environments.filter( | |
| 175 | (environment) => environment.environmentId !== primaryId, | |
| 176 | ); | |
| 177 | } | |
| 178 | await mountEditor(); | |
| 179 | const previousUpdate = renderer!.root.findByType("button").props.onClick as () => void; | |
| 180 | await act(() => { | |
| 181 | state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); | |
| 182 | }); | |
| 183 | previousUpdate(); | |
| 184 | ||
| 185 | if (mode === "primary") { | |
| 186 | expect(state.persist).toHaveBeenCalledExactlyOnceWith({ | |
| 187 | environmentId: primaryId, | |
| 188 | input: { patch }, | |
| 189 | }); | |
| 190 | expect(state.toast).not.toHaveBeenCalled(); | |
| 191 | } else { | |
| 192 | expect(state.persist).not.toHaveBeenCalled(); | |
| 193 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ | |
| 194 | type: "warning", | |
| 195 | title: "Setting not saved", | |
| 196 | description: "This connection does not have permission to change these settings.", | |
| 197 | }); | |
| 198 | } | |
| 199 | }, | |
| 200 | ); | |
| 201 | }); |
apps/web/src/hooks/useSettings.ts+65-6| ... | ... | @@ -13,6 +13,8 @@ import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 13 | 13 | import { useAtomValue } from "@effect/atom-react"; |
| 14 | 14 | import { |
| 15 | 15 | DEFAULT_SERVER_SETTINGS, |
| 16 | AuthSettingsWriteScope, | |
| 17 | requiredScopesForServerSettingsPatch, | |
| 16 | 18 | type EnvironmentId, |
| 17 | 19 | type ProviderInstanceMutation, |
| 18 | 20 | ServerSettings, |
| ... | ... | @@ -40,12 +42,16 @@ import { |
| 40 | 42 | themeAllowsSidebarArtwork, |
| 41 | 43 | } from "~/themePalette"; |
| 42 | 44 | import * as Struct from "effect/Struct"; |
| 45 | import * as Option from "effect/Option"; | |
| 46 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 43 | 47 | import { toastManager } from "~/components/ui/toast"; |
| 44 | 48 | import { isHostedStaticApp } from "~/hostedPairing"; |
| 45 | 49 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 46 | 50 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; |
| 47 | 51 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 48 | 52 | import { useTheme } from "./useTheme"; |
| 53 | import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 54 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 49 | 55 | |
| 50 | 56 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 51 | 57 | |
| ... | ... | @@ -425,6 +431,31 @@ export function usePrimarySettingsAvailable(): boolean { |
| 425 | 431 | return primaryEnvironment !== null || !isHostedStaticApp(); |
| 426 | 432 | } |
| 427 | 433 | |
| 434 | /** Connected sync targets, excluding grants already known to forbid settings writes. */ | |
| 435 | function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<EnvironmentId> { | |
| 436 | const { environments } = useEnvironments(); | |
| 437 | const writableTargetsAtom = useMemo( | |
| 438 | () => | |
| 439 | Atom.make((get) => | |
| 440 | environments.filter(supportsSharedSettingsSync).flatMap((environment) => { | |
| 441 | const result = get(environmentSession.sessionStateAtom(environment.environmentId)); | |
| 442 | // A cold grant must not drop a shared edit. The server authorizes the | |
| 443 | // write; mismatch suggestions still wait for a confirmed grant. | |
| 444 | if (includePending && result._tag === "Initial") { | |
| 445 | return [environment.environmentId]; | |
| 446 | } | |
| 447 | const session = | |
| 448 | result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)); | |
| 449 | return session?.authenticated && session.scopes?.includes(AuthSettingsWriteScope) | |
| 450 | ? [environment.environmentId] | |
| 451 | : []; | |
| 452 | }), | |
| 453 | ), | |
| 454 | [environments, includePending], | |
| 455 | ); | |
| 456 | return useAtomValue(writableTargetsAtom); | |
| 457 | } | |
| 458 | ||
| 428 | 459 | /** |
| 429 | 460 | * Returns an updater that routes each key to the correct backing store. |
| 430 | 461 | * |
| ... | ... | @@ -435,16 +466,31 @@ export function usePrimarySettingsAvailable(): boolean { |
| 435 | 466 | * through client persistence. |
| 436 | 467 | */ |
| 437 | 468 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 469 | // Mount this session even on pages without a visible permission-gated control. | |
| 470 | useEnvironmentScope(environmentId, AuthSettingsWriteScope); | |
| 438 | 471 | const persistServerSettings = useAtomCommand( |
| 439 | 472 | serverEnvironment.updateSettings, |
| 440 | 473 | "server settings update", |
| 441 | 474 | ); |
| 442 | 475 | const { environments } = useEnvironments(); |
| 476 | const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); | |
| 443 | 477 | const updateSettings = useCallback( |
| 444 | 478 | (patch: UnifiedSettingsPatch) => { |
| 445 | 479 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 446 | 480 | |
| 447 | if (Object.keys(serverPatch).length > 0) { | |
| 481 | const canWriteServerPatch = | |
| 482 | environmentId === null || | |
| 483 | requiredScopesForServerSettingsPatch(serverPatch).every((scope) => | |
| 484 | readEnvironmentScope(environmentId, scope), | |
| 485 | ); | |
| 486 | if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) { | |
| 487 | toastManager.add({ | |
| 488 | type: "warning", | |
| 489 | title: "Setting not saved", | |
| 490 | description: "This connection does not have permission to change these settings.", | |
| 491 | }); | |
| 492 | } | |
| 493 | if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { | |
| 448 | 494 | const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); |
| 449 | 495 | // Dropping the write silently leaves the control looking saved. |
| 450 | 496 | const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => |
| ... | ... | @@ -467,13 +513,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 467 | 513 | const sourceSettings = environments.find( |
| 468 | 514 | (target) => target.environmentId === environmentId, |
| 469 | 515 | )?.serverConfig?.settings; |
| 470 | const targets = new Set( | |
| 471 | environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), | |
| 472 | ); | |
| 516 | const targets = new Set(sharedSettingsSyncTargetIds); | |
| 473 | 517 | if (environmentId) { |
| 474 | 518 | targets.add(environmentId); |
| 475 | 519 | } |
| 476 | 520 | let wroteToTarget = false; |
| 521 | let permissionDenied = false; | |
| 477 | 522 | for (const targetId of targets) { |
| 478 | 523 | const target = environments.find((candidate) => candidate.environmentId === targetId); |
| 479 | 524 | const targetPatch = filterSharedServerPatch( |
| ... | ... | @@ -484,6 +529,16 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 484 | 529 | targetId === environmentId, |
| 485 | 530 | ); |
| 486 | 531 | if (Object.keys(targetPatch).length === 0) continue; |
| 532 | const session = appAtomRegistry.get(environmentSession.sessionStateAtom(targetId)); | |
| 533 | if ( | |
| 534 | session._tag !== "Initial" && | |
| 535 | !requiredScopesForServerSettingsPatch(sharedPatch).every((scope) => | |
| 536 | readEnvironmentScope(targetId, scope), | |
| 537 | ) | |
| 538 | ) { | |
| 539 | permissionDenied = true; | |
| 540 | continue; | |
| 541 | } | |
| 487 | 542 | wroteToTarget = true; |
| 488 | 543 | void persistServerSettings({ |
| 489 | 544 | environmentId: targetId, |
| ... | ... | @@ -492,7 +547,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 492 | 547 | } |
| 493 | 548 | if (!wroteToTarget) { |
| 494 | 549 | warnUnsaved( |
| 495 | targets.size > 0 ? "Update older servers to save this setting." : undefined, | |
| 550 | permissionDenied | |
| 551 | ? "This connection does not have permission to change these settings." | |
| 552 | : targets.size > 0 | |
| 553 | ? "Update older servers to save this setting." | |
| 554 | : undefined, | |
| 496 | 555 | ); |
| 497 | 556 | } |
| 498 | 557 | } |
| ... | ... | @@ -501,7 +560,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 501 | 560 | void persistClientSettingsPatch(clientPatch); |
| 502 | 561 | } |
| 503 | 562 | }, |
| 504 | [environmentId, environments, persistServerSettings], | |
| 563 | [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds], | |
| 505 | 564 | ); |
| 506 | 565 | |
| 507 | 566 | return updateSettings; |
apps/web/src/keybindings.test.ts+97| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | isTerminalSplitShortcut, |
| 26 | 26 | isTerminalSplitVerticalShortcut, |
| 27 | 27 | isTerminalToggleShortcut, |
| 28 | resolveChatShortcutCommand, | |
| 28 | 29 | resolveShortcutCommand, |
| 29 | 30 | shouldShowThreadJumpHintsForModifiers, |
| 30 | 31 | shortcutLabelForCommand, |
| ... | ... | @@ -886,6 +887,102 @@ describe("cross-command precedence", () => { |
| 886 | 887 | }); |
| 887 | 888 | }); |
| 888 | 889 | |
| 890 | describe("resolveChatShortcutCommand", () => { | |
| 891 | it("runs the active environment's script shortcut instead of the primary environment's", () => { | |
| 892 | const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); | |
| 893 | const active = compile([{ shortcut: modShortcut("t"), command: "script.setup.run" }]); | |
| 894 | ||
| 895 | assert.strictEqual( | |
| 896 | resolveChatShortcutCommand(event({ key: "t", ctrlKey: true }), primary, active, { | |
| 897 | platform: "Linux", | |
| 898 | }), | |
| 899 | "script.setup.run", | |
| 900 | ); | |
| 901 | assert.isNull( | |
| 902 | resolveChatShortcutCommand(event({ key: "r", ctrlKey: true }), primary, active, { | |
| 903 | platform: "Linux", | |
| 904 | }), | |
| 905 | ); | |
| 906 | }); | |
| 907 | ||
| 908 | it("resolves shared chords to the active environment's script ID", () => { | |
| 909 | const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); | |
| 910 | const active = compile([{ shortcut: modShortcut("r"), command: "script.deploy.run" }]); | |
| 911 | ||
| 912 | assert.strictEqual( | |
| 913 | resolveChatShortcutCommand(event({ key: "r", metaKey: true }), primary, active, { | |
| 914 | platform: "MacIntel", | |
| 915 | }), | |
| 916 | "script.deploy.run", | |
| 917 | ); | |
| 918 | }); | |
| 919 | ||
| 920 | it("does not run primary scripts before the active environment's bindings arrive", () => { | |
| 921 | const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); | |
| 922 | ||
| 923 | assert.isNull( | |
| 924 | resolveChatShortcutCommand(event({ key: "r", ctrlKey: true }), primary, [], { | |
| 925 | platform: "Linux", | |
| 926 | }), | |
| 927 | ); | |
| 928 | }); | |
| 929 | ||
| 930 | it("keeps primary app commands ahead of conflicting active script shortcuts", () => { | |
| 931 | const primary = compile([{ shortcut: modShortcut("k"), command: "commandPalette.toggle" }]); | |
| 932 | const active = compile([{ shortcut: modShortcut("k"), command: "script.setup.run" }]); | |
| 933 | ||
| 934 | assert.strictEqual( | |
| 935 | resolveChatShortcutCommand(event({ key: "k", ctrlKey: true }), primary, active, { | |
| 936 | platform: "Linux", | |
| 937 | }), | |
| 938 | "commandPalette.toggle", | |
| 939 | ); | |
| 940 | }); | |
| 941 | ||
| 942 | it("keeps app shortcuts on the primary environment when the active environment remaps them", () => { | |
| 943 | const primary = compile([{ shortcut: modShortcut("j"), command: "terminal.toggle" }]); | |
| 944 | const active = compile([{ shortcut: modShortcut("t"), command: "terminal.toggle" }]); | |
| 945 | ||
| 946 | assert.strictEqual( | |
| 947 | resolveChatShortcutCommand(event({ key: "j", ctrlKey: true }), primary, active, { | |
| 948 | platform: "Linux", | |
| 949 | }), | |
| 950 | "terminal.toggle", | |
| 951 | ); | |
| 952 | assert.isNull( | |
| 953 | resolveChatShortcutCommand(event({ key: "t", ctrlKey: true }), primary, active, { | |
| 954 | platform: "Linux", | |
| 955 | }), | |
| 956 | ); | |
| 957 | }); | |
| 958 | ||
| 959 | it.each([false, true])( | |
| 960 | "preserves active binding precedence with terminalFocus=%s", | |
| 961 | (terminalFocus) => { | |
| 962 | const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); | |
| 963 | const active = compile([ | |
| 964 | { shortcut: modShortcut("r"), command: "script.deploy.run" }, | |
| 965 | { | |
| 966 | shortcut: modShortcut("r"), | |
| 967 | command: "terminal.new", | |
| 968 | whenAst: whenIdentifier("terminalFocus"), | |
| 969 | }, | |
| 970 | ]); | |
| 971 | const options = { platform: "Linux", context: { terminalFocus } }; | |
| 972 | const shortcut = event({ key: "r", ctrlKey: true }); | |
| 973 | ||
| 974 | assert.strictEqual( | |
| 975 | resolveChatShortcutCommand(shortcut, primary, active, options), | |
| 976 | terminalFocus ? null : "script.deploy.run", | |
| 977 | ); | |
| 978 | assert.strictEqual( | |
| 979 | resolveChatShortcutCommand(shortcut, active, active, options), | |
| 980 | resolveShortcutCommand(shortcut, active, options), | |
| 981 | ); | |
| 982 | }, | |
| 983 | ); | |
| 984 | }); | |
| 985 | ||
| 889 | 986 | describe("resolveShortcutCommand", () => { |
| 890 | 987 | it("resolves a custom stop-thread shortcut", () => { |
| 891 | 988 | const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]); |
apps/web/src/keybindings.ts+19| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | type ShortcutModifierStateLike, |
| 19 | 19 | } from "@t3tools/shared/keybindings"; |
| 20 | 20 | import { isElectron } from "./env"; |
| 21 | import { projectScriptIdFromCommand } from "./projectScripts"; | |
| 21 | 22 | |
| 22 | 23 | export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; |
| 23 | 24 | export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; |
| ... | ... | @@ -169,6 +170,24 @@ export function resolveShortcutCommand( |
| 169 | 170 | return null; |
| 170 | 171 | } |
| 171 | 172 | |
| 173 | /** App shortcuts use the primary environment; script shortcuts belong to the active project. */ | |
| 174 | export function resolveChatShortcutCommand( | |
| 175 | event: ShortcutEventLike, | |
| 176 | primaryKeybindings: ResolvedKeybindingsConfig, | |
| 177 | activeKeybindings: ResolvedKeybindingsConfig, | |
| 178 | options?: ShortcutMatchOptions, | |
| 179 | ): KeybindingCommand | null { | |
| 180 | const primaryCommand = resolveShortcutCommand(event, primaryKeybindings, options); | |
| 181 | if (primaryCommand !== null && projectScriptIdFromCommand(primaryCommand) === null) { | |
| 182 | return primaryCommand; | |
| 183 | } | |
| 184 | ||
| 185 | const activeCommand = resolveShortcutCommand(event, activeKeybindings, options); | |
| 186 | return activeCommand !== null && projectScriptIdFromCommand(activeCommand) !== null | |
| 187 | ? activeCommand | |
| 188 | : null; | |
| 189 | } | |
| 190 | ||
| 172 | 191 | export function formatShortcutKeyLabel(key: string): string { |
| 173 | 192 | if (key === " ") return "Space"; |
| 174 | 193 | if (key.length === 1) return key.toUpperCase(); |
apps/web/src/lib/attachmentUploadQueue.test.ts+111| ... | ... | @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ |
| 18 | 18 | executeAtomQuery: vi.fn(), |
| 19 | 19 | removeUpload: Symbol("remove-upload"), |
| 20 | 20 | runAtomCommand: vi.fn(), |
| 21 | readEnvironmentScope: vi.fn(), | |
| 21 | 22 | readPreparedConnection: vi.fn(), |
| 22 | 23 | })); |
| 23 | 24 | |
| ... | ... | @@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({ |
| 48 | 49 | })); |
| 49 | 50 | |
| 50 | 51 | vi.mock("../state/session", () => ({ |
| 52 | readEnvironmentScope: mocks.readEnvironmentScope, | |
| 51 | 53 | readPreparedConnection: mocks.readPreparedConnection, |
| 52 | 54 | })); |
| 53 | 55 | |
| ... | ... | @@ -172,6 +174,7 @@ describe("attachmentUploadQueue", () => { |
| 172 | 174 | mocks.executeAtomQuery.mockReset(); |
| 173 | 175 | mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); |
| 174 | 176 | mocks.runAtomCommand.mockReset(); |
| 177 | mocks.readEnvironmentScope.mockReset().mockReturnValue(true); | |
| 175 | 178 | mocks.readPreparedConnection.mockReset(); |
| 176 | 179 | mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); |
| 177 | 180 | mocks.runAtomCommand.mockImplementation( |
| ... | ... | @@ -266,6 +269,114 @@ describe("attachmentUploadQueue", () => { |
| 266 | 269 | expect(readAttachmentUpload(image.id)).toBeUndefined(); |
| 267 | 270 | }); |
| 268 | 271 | |
| 272 | it("keeps an attachment local until its own environment grants write access", async () => { | |
| 273 | const image = makeImage("permission-gain"); | |
| 274 | mocks.readEnvironmentScope.mockImplementation( | |
| 275 | (environmentId) => environmentId === firstEnvironment, | |
| 276 | ); | |
| 277 | ||
| 278 | startAttachmentUpload({ environmentId: secondEnvironment, image }); | |
| 279 | expect(mocks.runAtomCommand).not.toHaveBeenCalled(); | |
| 280 | expect(TestXmlHttpRequest.requests).toHaveLength(0); | |
| 281 | expect(readAttachmentUpload(image.id)).toBeUndefined(); | |
| 282 | expect(image.file).not.toBeNull(); | |
| 283 | ||
| 284 | mocks.readEnvironmentScope.mockReturnValue(true); | |
| 285 | startAttachmentUpload({ environmentId: secondEnvironment, image }); | |
| 286 | await Promise.resolve(); | |
| 287 | const settled = awaitAttachmentUploads([image.id]); | |
| 288 | TestXmlHttpRequest.requests[0]!.complete(); | |
| 289 | await settled; | |
| 290 | ||
| 291 | expect(readAttachmentUpload(image.id)).toMatchObject({ | |
| 292 | status: "ready", | |
| 293 | environmentId: secondEnvironment, | |
| 294 | }); | |
| 295 | }); | |
| 296 | ||
| 297 | it("does not transfer bytes or retry when access is revoked while minting an upload", async () => { | |
| 298 | const image = makeImage("revoke-before-bytes"); | |
| 299 | let finishMint: (() => void) | undefined; | |
| 300 | mocks.runAtomCommand.mockImplementationOnce( | |
| 301 | () => | |
| 302 | new Promise((resolve) => { | |
| 303 | finishMint = () => | |
| 304 | resolve({ | |
| 305 | _tag: "Success", | |
| 306 | value: { | |
| 307 | attachmentId: "pending-revoked", | |
| 308 | relativeUrl: "/api/attachments/upload/pending-revoked", | |
| 309 | expiresAt: 1, | |
| 310 | }, | |
| 311 | }); | |
| 312 | }), | |
| 313 | ); | |
| 314 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | |
| 315 | const settled = awaitAttachmentUploads([image.id]); | |
| 316 | mocks.readEnvironmentScope.mockReturnValue(false); | |
| 317 | finishMint!(); | |
| 318 | await settled; | |
| 319 | ||
| 320 | expect(TestXmlHttpRequest.requests).toHaveLength(0); | |
| 321 | expect(readAttachmentUpload(image.id)).toMatchObject({ | |
| 322 | status: "failed", | |
| 323 | reason: "This connection cannot upload attachments.", | |
| 324 | attachmentId: "pending-revoked", | |
| 325 | }); | |
| 326 | retryAttachmentUpload({ environmentId: firstEnvironment, image }); | |
| 327 | expect(readAttachmentUpload(image.id)?.status).toBe("failed"); | |
| 328 | releaseAttachmentUpload(image.id); | |
| 329 | expect(readAttachmentUpload(image.id)).toBeUndefined(); | |
| 330 | expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); | |
| 331 | }); | |
| 332 | ||
| 333 | it("does not mint queued uploads after access is revoked", async () => { | |
| 334 | const images = Array.from({ length: 4 }, (_, index) => makeImage(`queued-${index}`)); | |
| 335 | for (const image of images) { | |
| 336 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | |
| 337 | } | |
| 338 | await Promise.resolve(); | |
| 339 | expect(TestXmlHttpRequest.requests).toHaveLength(3); | |
| 340 | const settled = awaitAttachmentUploads(images.map((image) => image.id)); | |
| 341 | mocks.readEnvironmentScope.mockReturnValue(false); | |
| 342 | for (const request of TestXmlHttpRequest.requests) { | |
| 343 | request.complete(); | |
| 344 | } | |
| 345 | await settled; | |
| 346 | ||
| 347 | expect(mocks.runAtomCommand).toHaveBeenCalledTimes(3); | |
| 348 | expect(TestXmlHttpRequest.requests).toHaveLength(3); | |
| 349 | expect(readAttachmentUpload(images[3]!.id)).toMatchObject({ | |
| 350 | status: "failed", | |
| 351 | reason: "This connection cannot upload attachments.", | |
| 352 | }); | |
| 353 | }); | |
| 354 | ||
| 355 | it("does not delete a persisted upload using another environment's grant", () => { | |
| 356 | mocks.readEnvironmentScope.mockImplementation( | |
| 357 | (environmentId) => environmentId === firstEnvironment, | |
| 358 | ); | |
| 359 | releasePersistedAttachmentUpload({ | |
| 360 | id: "local-file", | |
| 361 | environmentId: secondEnvironment, | |
| 362 | attachmentId: "persisted-file", | |
| 363 | }); | |
| 364 | expect(mocks.runAtomCommand).not.toHaveBeenCalled(); | |
| 365 | ||
| 366 | mocks.readEnvironmentScope.mockReturnValue(true); | |
| 367 | releasePersistedAttachmentUpload({ | |
| 368 | id: "local-file", | |
| 369 | environmentId: secondEnvironment, | |
| 370 | attachmentId: "persisted-file", | |
| 371 | }); | |
| 372 | expect(mocks.runAtomCommand).toHaveBeenCalledWith( | |
| 373 | expect.anything(), | |
| 374 | mocks.removeUpload, | |
| 375 | { environmentId: secondEnvironment, input: { attachmentId: "persisted-file" } }, | |
| 376 | expect.anything(), | |
| 377 | ); | |
| 378 | }); | |
| 379 | ||
| 269 | 380 | it("uploads images immediately and sends attachment references", async () => { |
| 270 | 381 | const image = { |
| 271 | 382 | ...makeImage("image-1"), |
apps/web/src/lib/attachmentUploadQueue.ts+33-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | AuthOrchestrationOperateScope, | |
| 2 | 3 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, |
| 3 | 4 | type ChatAttachment, |
| 4 | 5 | type EnvironmentId, |
| ... | ... | @@ -26,7 +27,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 26 | 27 | import { environmentCatalog } from "../connection/catalog"; |
| 27 | 28 | import { assetEnvironment } from "../state/assets"; |
| 28 | 29 | import { attachmentEnvironment } from "../state/attachments"; |
| 29 | import { readPreparedConnection } from "../state/session"; | |
| 30 | import { readEnvironmentScope, readPreparedConnection } from "../state/session"; | |
| 30 | 31 | import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; |
| 31 | 32 | |
| 32 | 33 | const MAX_UPLOADS_PER_ENVIRONMENT = 3; |
| ... | ... | @@ -138,6 +139,7 @@ function stampDraftFileUpload(job: UploadJob, attachmentId: string): void { |
| 138 | 139 | } |
| 139 | 140 | |
| 140 | 141 | function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void { |
| 142 | if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; | |
| 141 | 143 | deletePendingAttachmentUpload({ |
| 142 | 144 | registry: appAtomRegistry, |
| 143 | 145 | remove: attachmentEnvironment.remove, |
| ... | ... | @@ -179,6 +181,15 @@ function uploadBytes(input: { |
| 179 | 181 | } |
| 180 | 182 | |
| 181 | 183 | async function runUpload(job: UploadJob): Promise<void> { |
| 184 | if (!readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) { | |
| 185 | setUploadState(job.image.id, { | |
| 186 | status: "failed", | |
| 187 | environmentId: job.environmentId, | |
| 188 | reason: "This connection cannot upload attachments.", | |
| 189 | ...(job.previous ? { previous: job.previous } : {}), | |
| 190 | }); | |
| 191 | return; | |
| 192 | } | |
| 182 | 193 | if (job.persistedAttachmentId) { |
| 183 | 194 | const verification = await verifyPersistedAttachmentUpload({ |
| 184 | 195 | registry: appAtomRegistry, |
| ... | ... | @@ -260,6 +271,15 @@ async function runUpload(job: UploadJob): Promise<void> { |
| 260 | 271 | } |
| 261 | 272 | |
| 262 | 273 | let lastStep = -1; |
| 274 | if (!readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) { | |
| 275 | setUploadState(job.image.id, { | |
| 276 | status: "failed", | |
| 277 | environmentId: job.environmentId, | |
| 278 | reason: "This connection cannot upload attachments.", | |
| 279 | ...(job.previous ? { previous: job.previous } : {}), | |
| 280 | }); | |
| 281 | return; | |
| 282 | } | |
| 263 | 283 | const result = await runAttachmentUploadCycle({ |
| 264 | 284 | registry: appAtomRegistry, |
| 265 | 285 | createUploadUrl: attachmentEnvironment.createUploadUrl, |
| ... | ... | @@ -272,6 +292,12 @@ async function runUpload(job: UploadJob): Promise<void> { |
| 272 | 292 | sizeBytes: file.size, |
| 273 | 293 | }, |
| 274 | 294 | resolveUploadUrl: (relativeUrl) => { |
| 295 | if ( | |
| 296 | job.cancelled || | |
| 297 | !readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope) | |
| 298 | ) { | |
| 299 | return null; | |
| 300 | } | |
| 275 | 301 | const connection = readPreparedConnection(job.environmentId); |
| 276 | 302 | return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; |
| 277 | 303 | }, |
| ... | ... | @@ -295,7 +321,7 @@ async function runUpload(job: UploadJob): Promise<void> { |
| 295 | 321 | }, |
| 296 | 322 | }), |
| 297 | 323 | onMinted: (attachmentId) => { |
| 298 | if (job.cancelled) { | |
| 324 | if (job.cancelled && readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) { | |
| 299 | 325 | return "cancel"; |
| 300 | 326 | } |
| 301 | 327 | job.attachmentId = attachmentId; |
| ... | ... | @@ -328,7 +354,9 @@ async function runUpload(job: UploadJob): Promise<void> { |
| 328 | 354 | result.step === "mint" |
| 329 | 355 | ? "Upload could not start" |
| 330 | 356 | : result.step === "resolve-url" |
| 331 | ? "Not connected" | |
| 357 | ? readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope) | |
| 358 | ? "Not connected" | |
| 359 | : "This connection cannot upload attachments." | |
| 332 | 360 | : result.error instanceof Error |
| 333 | 361 | ? result.error.message |
| 334 | 362 | : "Upload failed", |
| ... | ... | @@ -388,6 +416,7 @@ export function startAttachmentUpload(input: { |
| 388 | 416 | /** Draft that owns the file; lets a background completion persist its ids. */ |
| 389 | 417 | readonly draftTarget?: ComposerThreadTarget; |
| 390 | 418 | }): void { |
| 419 | if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return; | |
| 391 | 420 | const existingJob = jobsByImageId.get(input.image.id); |
| 392 | 421 | if (existingJob?.environmentId === input.environmentId) { |
| 393 | 422 | return; |
| ... | ... | @@ -545,6 +574,7 @@ export function retryAttachmentUpload(input: { |
| 545 | 574 | readonly image: ComposerImageAttachment | ComposerFileAttachment; |
| 546 | 575 | readonly draftTarget?: ComposerThreadTarget; |
| 547 | 576 | }): void { |
| 577 | if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return; | |
| 548 | 578 | const previous = readAttachmentUpload(input.image.id); |
| 549 | 579 | cancelAttachmentUpload(input.image.id); |
| 550 | 580 | // A failed state's `attachmentId` is always one this queue minted, so this |
apps/web/src/lib/resourceTelemetryState.ts+5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | ResourceTelemetryHistoryInput, |
| ... | ... | @@ -9,6 +10,7 @@ import { useCallback } from "react"; |
| 9 | 10 | import { usePrimaryEnvironment } from "../state/environments"; |
| 10 | 11 | import { useEnvironmentQuery } from "../state/query"; |
| 11 | 12 | import { serverEnvironment } from "../state/server"; |
| 13 | import { readEnvironmentScope } from "../state/session"; | |
| 12 | 14 | import { useAtomCommand } from "../state/use-atom-command"; |
| 13 | 15 | |
| 14 | 16 | export interface ResourceTelemetryState { |
| ... | ... | @@ -39,6 +41,9 @@ export function useResourceTelemetry( |
| 39 | 41 | if (environmentId === null) { |
| 40 | 42 | throw new Error("No environment is selected."); |
| 41 | 43 | } |
| 44 | if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) { | |
| 45 | throw new Error("This connection cannot restart the resource monitor."); | |
| 46 | } | |
| 42 | 47 | const result = await retryCommand({ environmentId, input: {} }); |
| 43 | 48 | if (result._tag === "Failure") { |
| 44 | 49 | throw Cause.squash(result.cause); |
apps/web/src/routes/__root.tsx+9-43| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| 2 | 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 4 | 3 | import { |
| 5 | 4 | Outlet, |
| 6 | 5 | Link, |
| ... | ... | @@ -20,6 +19,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 20 | 19 | import { CommandPalette } from "../components/CommandPalette"; |
| 21 | 20 | import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; |
| 22 | 21 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; |
| 22 | import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning"; | |
| 23 | 23 | import { FirstRunGate } from "../components/onboarding/FirstRunGate"; |
| 24 | 24 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; |
| 25 | 25 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; |
| ... | ... | @@ -49,7 +49,6 @@ import { |
| 49 | 49 | ToastProvider, |
| 50 | 50 | toastManager, |
| 51 | 51 | } from "../components/ui/toast"; |
| 52 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | |
| 53 | 52 | import { isElectron } from "../env"; |
| 54 | 53 | import { cn } from "../lib/utils"; |
| 55 | 54 | import { applyAppearanceFontVariables } from "~/appearanceFonts"; |
| ... | ... | @@ -66,9 +65,7 @@ import { configureClientTracing } from "../observability/clientTracing"; |
| 66 | 65 | import { resolveInitialServerAuthGateState } from "../environments/primary"; |
| 67 | 66 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; |
| 68 | 67 | import { isLocalEnvironmentDisabled } from "../localEnvironment"; |
| 69 | import { shellEnvironment } from "../state/shell"; | |
| 70 | 68 | import { useAtomValue } from "@effect/atom-react"; |
| 71 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 72 | 69 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 73 | 70 | import { |
| 74 | 71 | primaryServerConfigAtom, |
| ... | ... | @@ -496,9 +493,6 @@ function EventRouter({ |
| 496 | 493 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 497 | 494 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 498 | 495 | const primaryEnvironment = usePrimaryEnvironment(); |
| 499 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { | |
| 500 | reportFailure: false, | |
| 501 | }); | |
| 502 | 496 | const serverConfig = useAtomValue(primaryServerConfigAtom); |
| 503 | 497 | const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom); |
| 504 | 498 | const serverWelcome = useAtomValue(primaryServerWelcomeAtom); |
| ... | ... | @@ -575,42 +569,14 @@ function EventRouter({ |
| 575 | 569 | stackedThreadToast({ |
| 576 | 570 | type: "warning", |
| 577 | 571 | title: "Invalid keybindings configuration", |
| 578 | description: decision.message, | |
| 579 | actionVariant: "outline", | |
| 580 | actionProps: { | |
| 581 | children: "Open keybindings.json", | |
| 582 | onClick: () => { | |
| 583 | if (!serverConfig || !primaryEnvironment) { | |
| 584 | return; | |
| 585 | } | |
| 586 | ||
| 587 | const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors); | |
| 588 | if (!editor) { | |
| 589 | return; | |
| 590 | } | |
| 591 | void (async () => { | |
| 592 | const result = await openInEditor({ | |
| 593 | environmentId: primaryEnvironment.environmentId, | |
| 594 | input: { | |
| 595 | cwd: serverConfig.keybindingsConfigPath, | |
| 596 | editor, | |
| 597 | }, | |
| 598 | }); | |
| 599 | if (result._tag === "Success") { | |
| 600 | return; | |
| 601 | } | |
| 602 | const error = squashAtomCommandFailure(result); | |
| 603 | toastManager.add( | |
| 604 | stackedThreadToast({ | |
| 605 | type: "error", | |
| 606 | title: "Unable to open keybindings file", | |
| 607 | description: | |
| 608 | error instanceof Error ? error.message : "Unknown error opening file.", | |
| 609 | }), | |
| 610 | ); | |
| 611 | })(); | |
| 612 | }, | |
| 613 | }, | |
| 572 | description: ( | |
| 573 | <KeybindingsConfigWarning | |
| 574 | message={decision.message} | |
| 575 | environmentId={primaryEnvironment?.environmentId ?? null} | |
| 576 | configPath={serverConfig?.keybindingsConfigPath ?? null} | |
| 577 | availableEditors={serverConfig?.availableEditors ?? []} | |
| 578 | /> | |
| 579 | ), | |
| 614 | 580 | }), |
| 615 | 581 | ); |
| 616 | 582 | }); |
apps/web/src/state/session.ts+62-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; | |
| 4 | import { useMemo } from "react"; | |
| 4 | 5 | import * as Option from "effect/Option"; |
| 5 | 6 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 6 | 7 | |
| ... | ... | @@ -9,6 +10,66 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 9 | 10 | |
| 10 | 11 | export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); |
| 11 | 12 | |
| 13 | const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>()); | |
| 14 | ||
| 15 | /** Uses the selected environment's grant, including cached scopes during a refresh. */ | |
| 16 | export function useEnvironmentScope( | |
| 17 | environmentId: EnvironmentId | null, | |
| 18 | scope: AuthEnvironmentScope, | |
| 19 | ): boolean { | |
| 20 | const result = useAtomValue( | |
| 21 | environmentId === null | |
| 22 | ? EMPTY_SESSION_STATE_ATOM | |
| 23 | : environmentSession.sessionStateAtom(environmentId), | |
| 24 | ); | |
| 25 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 26 | return ( | |
| 27 | result._tag !== "Failure" && | |
| 28 | session?.authenticated === true && | |
| 29 | session.scopes?.includes(scope) === true | |
| 30 | ); | |
| 31 | } | |
| 32 | ||
| 33 | /** Subscribe to the grants of every selected environment. */ | |
| 34 | export function useEnvironmentsWithScope( | |
| 35 | environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>, | |
| 36 | scope: AuthEnvironmentScope, | |
| 37 | ): ReadonlySet<EnvironmentId> { | |
| 38 | const permitted = useMemo( | |
| 39 | () => | |
| 40 | Atom.make((get) => { | |
| 41 | const ids = new Set<EnvironmentId>(); | |
| 42 | for (const { environmentId } of environments) { | |
| 43 | const result = get(environmentSession.sessionStateAtom(environmentId)); | |
| 44 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 45 | if ( | |
| 46 | result._tag !== "Failure" && | |
| 47 | session?.authenticated === true && | |
| 48 | session.scopes?.includes(scope) | |
| 49 | ) { | |
| 50 | ids.add(environmentId); | |
| 51 | } | |
| 52 | } | |
| 53 | return ids; | |
| 54 | }), | |
| 55 | [environments, scope], | |
| 56 | ); | |
| 57 | return useAtomValue(permitted); | |
| 58 | } | |
| 59 | ||
| 60 | export function readEnvironmentScope( | |
| 61 | environmentId: EnvironmentId, | |
| 62 | scope: AuthEnvironmentScope, | |
| 63 | ): boolean { | |
| 64 | const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); | |
| 65 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 66 | return ( | |
| 67 | result._tag !== "Failure" && | |
| 68 | session?.authenticated === true && | |
| 69 | session.scopes?.includes(scope) === true | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 12 | 73 | const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( |
| 13 | 74 | Atom.withLabel("web-prepared-connection:empty"), |
| 14 | 75 | ); |
apps/web/src/state/use-orchestration-command.test.ts created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | import type { AtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 2 | import { | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | EnvironmentAuthorizationError, | |
| 5 | EnvironmentId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import { AsyncResult } from "effect/reactivity"; | |
| 9 | import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | const state = vi.hoisted(() => ({ | |
| 12 | allowed: new Set<string>(), | |
| 13 | readScope: vi.fn(), | |
| 14 | run: vi.fn(), | |
| 15 | })); | |
| 16 | ||
| 17 | vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); | |
| 18 | vi.mock("./session", () => ({ readEnvironmentScope: state.readScope })); | |
| 19 | vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run })); | |
| 20 | ||
| 21 | import { useOrchestrationCommand } from "./use-orchestration-command"; | |
| 22 | ||
| 23 | const first = EnvironmentId.make("primary"); | |
| 24 | const second = EnvironmentId.make("secondary"); | |
| 25 | type Target = { environmentId: EnvironmentId; input: { threadId: string } }; | |
| 26 | const command: AtomCommand<Target, string, Error> = { label: "thread mutation", run: state.run }; | |
| 27 | const target = (environmentId: EnvironmentId): Target => ({ | |
| 28 | environmentId, | |
| 29 | input: { threadId: "thread-1" }, | |
| 30 | }); | |
| 31 | ||
| 32 | beforeEach(() => { | |
| 33 | state.allowed.clear(); | |
| 34 | state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt")); | |
| 35 | state.readScope | |
| 36 | .mockReset() | |
| 37 | .mockImplementation( | |
| 38 | (environmentId, scope) => | |
| 39 | scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId), | |
| 40 | ); | |
| 41 | }); | |
| 42 | ||
| 43 | it("checks the command's environment instead of borrowing the primary grant", async () => { | |
| 44 | state.allowed.add(first); | |
| 45 | const mutate = useOrchestrationCommand(command, { reportFailure: false }); | |
| 46 | const result = await mutate(target(second)); | |
| 47 | assert(result._tag === "Failure"); | |
| 48 | const error = Cause.squash(result.cause); | |
| 49 | expect(error).toBeInstanceOf(EnvironmentAuthorizationError); | |
| 50 | expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); | |
| 51 | expect(state.run).not.toHaveBeenCalled(); | |
| 52 | ||
| 53 | expect(await mutate(target(first))).toMatchObject({ _tag: "Success", value: "receipt" }); | |
| 54 | expect(state.run).toHaveBeenCalledWith(target(first)); | |
| 55 | }); | |
| 56 | ||
| 57 | it("a retained callback follows both revocation and a new grant without remounting", async () => { | |
| 58 | state.allowed.add(second); | |
| 59 | const mutate = useOrchestrationCommand(command); | |
| 60 | state.allowed.delete(second); | |
| 61 | expect((await mutate(target(second)))._tag).toBe("Failure"); | |
| 62 | expect(state.run).not.toHaveBeenCalled(); | |
| 63 | ||
| 64 | state.allowed.add(second); | |
| 65 | expect((await mutate(target(second)))._tag).toBe("Success"); | |
| 66 | expect(state.run).toHaveBeenCalledTimes(1); | |
| 67 | }); | |
| 68 | ||
| 69 | it("does not dispatch a later mutation after access changes while awaiting a receipt", async () => { | |
| 70 | state.allowed.add(second); | |
| 71 | let finish: (() => void) | undefined; | |
| 72 | state.run.mockImplementationOnce( | |
| 73 | () => | |
| 74 | new Promise((resolve) => { | |
| 75 | finish = () => resolve(AsyncResult.success("first receipt")); | |
| 76 | }), | |
| 77 | ); | |
| 78 | const mutate = useOrchestrationCommand(command); | |
| 79 | const pending = mutate(target(second)); | |
| 80 | state.allowed.delete(second); | |
| 81 | finish!(); | |
| 82 | expect((await pending)._tag).toBe("Success"); | |
| 83 | expect((await mutate(target(second)))._tag).toBe("Failure"); | |
| 84 | expect(state.run).toHaveBeenCalledTimes(1); | |
| 85 | }); |
apps/web/src/state/use-orchestration-command.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import type { | |
| 2 | AtomCommand, | |
| 3 | AtomCommandOptions, | |
| 4 | AtomCommandResult, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | import { | |
| 7 | AuthOrchestrationOperateScope, | |
| 8 | EnvironmentAuthorizationError, | |
| 9 | type EnvironmentId, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Cause from "effect/Cause"; | |
| 12 | import { AsyncResult } from "effect/reactivity"; | |
| 13 | import { useCallback } from "react"; | |
| 14 | ||
| 15 | import { readEnvironmentScope } from "./session"; | |
| 16 | import { useAtomCommand } from "./use-atom-command"; | |
| 17 | ||
| 18 | export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = | |
| 19 | "This connection cannot change threads or projects."; | |
| 20 | ||
| 21 | /** Recheck the target grant for every step of a thread or project mutation. */ | |
| 22 | export function useOrchestrationCommand<W extends { readonly environmentId: EnvironmentId }, A, E>( | |
| 23 | command: AtomCommand<W, A, E>, | |
| 24 | options?: string | AtomCommandOptions, | |
| 25 | ): (value: W) => Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> { | |
| 26 | const run = useAtomCommand(command, options); | |
| 27 | return useCallback( | |
| 28 | async (value: W): Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> => { | |
| 29 | if (!readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope)) { | |
| 30 | return AsyncResult.failure( | |
| 31 | Cause.fail( | |
| 32 | new EnvironmentAuthorizationError({ | |
| 33 | requiredScope: AuthOrchestrationOperateScope, | |
| 34 | message: ORCHESTRATION_OPERATE_DENIED_MESSAGE, | |
| 35 | }), | |
| 36 | ), | |
| 37 | ); | |
| 38 | } | |
| 39 | return run(value); | |
| 40 | }, | |
| 41 | [run], | |
| 42 | ); | |
| 43 | } |
docs/internals/environment-auth.md+5| ... | ... | @@ -58,6 +58,11 @@ extra authority: [every RPC declares a required |
| 58 | 58 | scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC |
| 59 | 59 | group's `RpcScopeAuthorization` middleware checks it before any handler runs. |
| 60 | 60 | |
| 61 | Self-update must work across authorization protocol changes. New servers advertise | |
| 62 | `auth.serverUpdateScope`; only an older server that omits it uses | |
| 63 | `orchestration:operate` for updates. An unchanged grant on an upgraded server must | |
| 64 | still include `environment:maintain`. | |
| 65 | ||
| 61 | 66 | Desktop restarts forget the previous local bearer token, so its reusable |
| 62 | 67 | bootstrap grant replaces earlier sessions for the same subject and method. |
| 63 | 68 | Revocation and insertion share a [database |
docs/user/remote-access.md+10| ... | ... | @@ -277,6 +277,16 @@ in web or desktop, use **Add Environment** with the fresh link or code; pairing |
| 277 | 277 | the same environment replaces its saved grant. Reconnecting alone does not change |
| 278 | 278 | permissions. |
| 279 | 279 | |
| 280 | Grouping checkouts does not combine their permissions. Shared project settings | |
| 281 | require `orchestration:operate` on every member environment; actions on one | |
| 282 | checkout use that checkout's permissions. | |
| 283 | ||
| 284 | Settings changes, provider management, and environment maintenance can be granted | |
| 285 | separately from access administration. New standard pairings include these | |
| 286 | permissions. Existing clients keep their original grants after an update; to | |
| 287 | receive newly separated permissions, pair the client again with the scopes it | |
| 288 | needs. Reconnecting or refreshing a session does not expand its grant. | |
| 289 | ||
| 280 | 290 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 281 | 291 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 282 | 292 | revokes its cloud access and frees its host space even when the environment is |
oxlint-plugin-t3code/index.ts+2| ... | ... | @@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e |
| 9 | 9 | import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; |
| 10 | 10 | import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; |
| 11 | 11 | import noTestInLoop from "./rules/no-test-in-loop.ts"; |
| 12 | import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; | |
| 12 | 13 | import noUnscopedHas from "./rules/no-unscoped-has.ts"; |
| 13 | 14 | import preferCatchTags from "./rules/prefer-catch-tags.ts"; |
| 14 | 15 | import requireSuppressionReason from "./rules/require-suppression-reason.ts"; |
| ... | ... | @@ -27,6 +28,7 @@ export default definePlugin({ |
| 27 | 28 | "no-native-title-tooltip": noNativeTitleTooltip, |
| 28 | 29 | "no-raw-mcp-registration": noRawMcpRegistration, |
| 29 | 30 | "no-test-in-loop": noTestInLoop, |
| 31 | "no-rpc-permission-bypass": noRpcPermissionBypass, | |
| 30 | 32 | "no-unscoped-has": noUnscopedHas, |
| 31 | 33 | "prefer-catch-tags": preferCatchTags, |
| 32 | 34 | "require-suppression-reason": requireSuppressionReason, |
oxlint-plugin-t3code/rules/no-rpc-permission-bypass.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import { createOxlintRuleHarness } from "../test/utils.ts"; | |
| 2 | const state = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { | |
| 3 | filename: "fixture.ts", | |
| 4 | }); | |
| 5 | state.invalid("blocks direct session calls", "session.client[tag](input);"); | |
| 6 | state.invalid("blocks extracting the raw client", 'const raw = session["client"];'); | |
| 7 | state.invalid( | |
| 8 | "blocks replacing the permission guard", | |
| 9 | 'import { RpcPermissionGuard as Guard } from "../rpc/client.ts";', | |
| 10 | ); | |
| 11 | state.invalid( | |
| 12 | "blocks namespace guard access", | |
| 13 | 'import * as Rpc from "../rpc/client.ts"; const guard = Rpc.RpcPermissionGuard;', | |
| 14 | ); | |
| 15 | state.invalid("blocks raw protocol imports", 'import { makeClient } from "../rpc/protocol.ts";'); | |
| 16 | state.valid( | |
| 17 | "allows typed requests", | |
| 18 | 'import { request } from "../rpc/client.ts"; request(method, input);', | |
| 19 | ); | |
| 20 | const rpc = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { | |
| 21 | filename: "fixture.ts", | |
| 22 | ruleOptions: [{ allowRawClientAccess: true }], | |
| 23 | }); | |
| 24 | rpc.valid("allows the transport boundary", "session.client[tag](input);"); | |
| 25 | ||
| 26 | const app = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { | |
| 27 | filename: "fixture.ts", | |
| 28 | }); | |
| 29 | app.invalid( | |
| 30 | "blocks guard imports through the barrel", | |
| 31 | 'import { RpcPermissionGuard as Guard } from "@t3tools/client-runtime/rpc";', | |
| 32 | ); | |
| 33 | app.invalid( | |
| 34 | "blocks explicit index imports", | |
| 35 | 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc/index.ts";', | |
| 36 | ); | |
| 37 | app.invalid( | |
| 38 | "blocks raw session access in subscription callbacks", | |
| 39 | "subscribeDynamic(tag, session => session.client[method](input));", | |
| 40 | ); | |
| 41 | app.invalid( | |
| 42 | "blocks raw client destructuring", | |
| 43 | "const { client: raw } = session; raw[method](input);", | |
| 44 | ); | |
| 45 | app.valid( | |
| 46 | "allows public typed RPC helpers", | |
| 47 | 'import { request, runStream } from "@t3tools/client-runtime/rpc";', | |
| 48 | ); | |
| 49 | ||
| 50 | rpc.invalid( | |
| 51 | "raw client allowance does not permit installing the guard", | |
| 52 | 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc";', | |
| 53 | ); | |
| 54 | const boundary = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { | |
| 55 | filename: "fixture.ts", | |
| 56 | ruleOptions: [{ allowGuardInstallation: true }], | |
| 57 | }); | |
| 58 | boundary.valid( | |
| 59 | "permits guard installation when configured", | |
| 60 | 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc"; RpcPermissionGuard.of({ authorize });', | |
| 61 | ); | |
| 62 | boundary.invalid( | |
| 63 | "guard installation allowance does not permit raw clients", | |
| 64 | "session.client[tag](input);", | |
| 65 | ); | |
| 66 | boundary.invalid( | |
| 67 | "guard installation allowance does not permit raw protocol imports", | |
| 68 | 'import { makeWsRpcProtocolClient } from "../rpc/protocol.ts";', | |
| 69 | ); |
oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts created+91| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | import { defineRule } from "@oxlint/plugins"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import { getPropertyName } from "../utils.ts"; | |
| 4 | ||
| 5 | const readOptions = (options: ReadonlyArray<unknown>) => { | |
| 6 | const [first] = options; | |
| 7 | return { | |
| 8 | allowRawClientAccess: | |
| 9 | typeof first === "object" && | |
| 10 | first !== null && | |
| 11 | "allowRawClientAccess" in first && | |
| 12 | first.allowRawClientAccess === true, | |
| 13 | allowGuardInstallation: | |
| 14 | typeof first === "object" && | |
| 15 | first !== null && | |
| 16 | "allowGuardInstallation" in first && | |
| 17 | first.allowGuardInstallation === true, | |
| 18 | }; | |
| 19 | }; | |
| 20 | ||
| 21 | /** A guardrail for ordinary edits, not a security boundary against deliberate casts or aliases. */ | |
| 22 | export default defineRule({ | |
| 23 | meta: { | |
| 24 | type: "problem", | |
| 25 | docs: { | |
| 26 | description: | |
| 27 | "Keep raw RPC access and permission guard installation inside their shared boundaries.", | |
| 28 | }, | |
| 29 | schema: [ | |
| 30 | { | |
| 31 | type: "object", | |
| 32 | properties: { | |
| 33 | allowRawClientAccess: { | |
| 34 | type: "boolean", | |
| 35 | description: "Permit .client access for non-RPC clients in the configured files.", | |
| 36 | }, | |
| 37 | allowGuardInstallation: { | |
| 38 | type: "boolean", | |
| 39 | description: "Permit installing RpcPermissionGuard in a reviewed command boundary.", | |
| 40 | }, | |
| 41 | }, | |
| 42 | additionalProperties: false, | |
| 43 | }, | |
| 44 | ], | |
| 45 | defaultOptions: [{ allowRawClientAccess: false, allowGuardInstallation: false }], | |
| 46 | }, | |
| 47 | create(context) { | |
| 48 | const { allowRawClientAccess, allowGuardInstallation } = readOptions(context.options); | |
| 49 | const report = (node: NonNullable<Parameters<typeof context.report>[0]["node"]>) => | |
| 50 | context.report({ | |
| 51 | node, | |
| 52 | message: | |
| 53 | "Use permission-aware environment commands. Raw RPC access and RpcPermissionGuard belong in the shared RPC/command boundary.", | |
| 54 | }); | |
| 55 | return { | |
| 56 | ImportDeclaration(node) { | |
| 57 | if (typeof node.source.value !== "string") return; | |
| 58 | const source = node.source.value; | |
| 59 | if (/(?:^|\/)rpc\/protocol(?:\.ts)?$/.test(source) && node.importKind !== "type") | |
| 60 | report(node); | |
| 61 | if ( | |
| 62 | !allowGuardInstallation && | |
| 63 | /(?:^|\/)rpc(?:\/(?:client|index)(?:\.ts)?)?$/.test(source) | |
| 64 | ) { | |
| 65 | for (const specifier of node.specifiers) { | |
| 66 | if ( | |
| 67 | specifier.type === "ImportSpecifier" && | |
| 68 | Option.getOrNull(getPropertyName(specifier.imported)) === "RpcPermissionGuard" | |
| 69 | ) | |
| 70 | report(specifier); | |
| 71 | } | |
| 72 | } | |
| 73 | }, | |
| 74 | VariableDeclarator(node) { | |
| 75 | if (allowRawClientAccess || node.id.type !== "ObjectPattern") return; | |
| 76 | for (const property of node.id.properties) { | |
| 77 | if ( | |
| 78 | property.type === "Property" && | |
| 79 | Option.getOrNull(getPropertyName(property.key)) === "client" | |
| 80 | ) | |
| 81 | report(property); | |
| 82 | } | |
| 83 | }, | |
| 84 | MemberExpression(node) { | |
| 85 | const property = Option.getOrNull(getPropertyName(node.property)); | |
| 86 | if (!allowGuardInstallation && property === "RpcPermissionGuard") report(node); | |
| 87 | if (!allowRawClientAccess && property === "client") report(node); | |
| 88 | }, | |
| 89 | }; | |
| 90 | }, | |
| 91 | }); |
packages/client-runtime/src/rpc/client.ts+57-4| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 | import { |
| 2 | 2 | EnvironmentAuthorizationError, |
| 3 | clientRpcRequiredScopes, | |
| 4 | type EnvironmentId, | |
| 5 | type ClientGuardedRpcTag, | |
| 3 | 6 | ORCHESTRATION_V2_WS_METHODS, |
| 4 | 7 | WS_METHODS, |
| 5 | 8 | } from "@t3tools/contracts"; |
| ... | ... | @@ -146,7 +149,40 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer |
| 146 | 149 | }, |
| 147 | 150 | ); |
| 148 | 151 | |
| 149 | export const request = Effect.fn("EnvironmentRpc.request")(function* < | |
| 152 | /** Installed by the shared command boundary. A missing boundary denies protected writes. */ | |
| 153 | export class RpcPermissionGuard extends Context.Reference<{ | |
| 154 | readonly authorize: ( | |
| 155 | environmentId: EnvironmentId, | |
| 156 | method: string, | |
| 157 | input: unknown, | |
| 158 | ) => Effect.Effect<void, EnvironmentAuthorizationError>; | |
| 159 | }>("@t3tools/client-runtime/rpc/RpcPermissionGuard", { | |
| 160 | defaultValue: () => ({ | |
| 161 | authorize: (_environmentId, method, input) => { | |
| 162 | const scope = clientRpcRequiredScopes(method, input)[0]; | |
| 163 | return scope === undefined | |
| 164 | ? Effect.void | |
| 165 | : Effect.fail( | |
| 166 | new EnvironmentAuthorizationError({ | |
| 167 | requiredScope: scope, | |
| 168 | message: `This connection requires ${scope}.`, | |
| 169 | }), | |
| 170 | ); | |
| 171 | }, | |
| 172 | }), | |
| 173 | }) {} | |
| 174 | ||
| 175 | const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( | |
| 176 | method: string, | |
| 177 | input: unknown, | |
| 178 | ) { | |
| 179 | if (clientRpcRequiredScopes(method, input).length === 0) return; | |
| 180 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; | |
| 181 | const guard = yield* RpcPermissionGuard; | |
| 182 | yield* guard.authorize(supervisor.target.environmentId, method, input); | |
| 183 | }); | |
| 184 | ||
| 185 | export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < | |
| 150 | 186 | TTag extends EnvironmentUnaryRpcTag, |
| 151 | 187 | >(tag: TTag, input: EnvironmentRpcInput<TTag>) { |
| 152 | 188 | const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; |
| ... | ... | @@ -155,6 +191,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* < |
| 155 | 191 | "rpc.method": tag, |
| 156 | 192 | }); |
| 157 | 193 | const session = yield* currentSession(); |
| 194 | yield* authorizeRequest(tag, input); | |
| 158 | 195 | const observer = yield* EnvironmentRpcRequestObserver; |
| 159 | 196 | const method = session.client[tag] as ( |
| 160 | 197 | input: EnvironmentRpcInput<TTag>, |
| ... | ... | @@ -166,16 +203,19 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* < |
| 166 | 203 | return yield* method(input).pipe(Effect.ensuring(completeObservation)); |
| 167 | 204 | }); |
| 168 | 205 | |
| 169 | export function runStream<TTag extends EnvironmentStreamCommandRpcTag>( | |
| 206 | function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>( | |
| 170 | 207 | tag: TTag, |
| 171 | 208 | input: EnvironmentRpcInput<TTag>, |
| 172 | 209 | ): Stream.Stream< |
| 173 | 210 | EnvironmentRpcStreamValue<TTag>, |
| 174 | EnvironmentRpcStreamFailure<TTag> | EnvironmentRpcUnavailableError, | |
| 211 | | EnvironmentRpcStreamFailure<TTag> | |
| 212 | | EnvironmentRpcUnavailableError | |
| 213 | | EnvironmentAuthorizationError, | |
| 175 | 214 | EnvironmentSupervisor.EnvironmentSupervisor |
| 176 | 215 | > { |
| 177 | 216 | return Stream.unwrap( |
| 178 | currentSession().pipe( | |
| 217 | authorizeRequest(tag, input).pipe( | |
| 218 | Effect.andThen(currentSession()), | |
| 179 | 219 | Effect.map((session) => { |
| 180 | 220 | const method = session.client[tag] as ( |
| 181 | 221 | input: EnvironmentRpcInput<TTag>, |
| ... | ... | @@ -393,3 +433,16 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( |
| 393 | 433 | > { |
| 394 | 434 | return subscribeDynamic(tag, () => Effect.succeed(input), options); |
| 395 | 435 | } |
| 436 | ||
| 437 | /** Protected writes must go through the permission-aware command layer. */ | |
| 438 | export const request = <TTag extends Exclude<EnvironmentUnaryRpcTag, ClientGuardedRpcTag>>( | |
| 439 | tag: TTag, | |
| 440 | input: EnvironmentRpcInput<TTag>, | |
| 441 | ) => requestGuarded(tag, input); | |
| 442 | ||
| 443 | export const runStream = < | |
| 444 | TTag extends Exclude<EnvironmentStreamCommandRpcTag, ClientGuardedRpcTag>, | |
| 445 | >( | |
| 446 | tag: TTag, | |
| 447 | input: EnvironmentRpcInput<TTag>, | |
| 448 | ) => runStreamGuarded(tag, input); |
packages/client-runtime/src/rpc/index.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | 1 | export * from "./client.ts"; |
| 2 | 2 | export * from "./http.ts"; |
| 3 | export * from "./protocol.ts"; | |
| 3 | export type { WsRpcProtocolClient } from "./protocol.ts"; | |
| 4 | 4 | export { type RpcSession } from "./session.ts"; |
packages/client-runtime/src/state/commandPermissions.test.ts created+152| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { vi } from "vite-plus/test"; | |
| 3 | import { | |
| 4 | AuthOrchestrationOperateScope, | |
| 5 | EnvironmentId, | |
| 6 | ScheduledTaskId, | |
| 7 | WS_METHODS, | |
| 8 | type AuthSessionState, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Cause from "effect/Cause"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as TestClock from "effect/testing/TestClock"; | |
| 15 | import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; | |
| 16 | import { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 17 | import { createCommandPermissions } from "./commandPermissions.ts"; | |
| 18 | import { createEnvironmentRpcCommand } from "./runtime.ts"; | |
| 19 | ||
| 20 | vi.mock("./session.ts", () => ({ | |
| 21 | createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }), | |
| 22 | })); | |
| 23 | const sessions = Atom.family((_id: EnvironmentId) => | |
| 24 | Atom.make<AsyncResult.AsyncResult<AuthSessionState, string>>(AsyncResult.initial()), | |
| 25 | ); | |
| 26 | const env = EnvironmentId.make("target"); | |
| 27 | const other = EnvironmentId.make("other"); | |
| 28 | const grant = (allowed: boolean): AuthSessionState => ({ | |
| 29 | authenticated: true, | |
| 30 | auth: { | |
| 31 | policy: "remote-reachable", | |
| 32 | bootstrapMethods: [], | |
| 33 | sessionMethods: [], | |
| 34 | sessionCookieName: "test", | |
| 35 | }, | |
| 36 | scopes: allowed ? [AuthOrchestrationOperateScope] : [], | |
| 37 | }); | |
| 38 | const runtime = Atom.runtime( | |
| 39 | Layer.succeed(EnvironmentRegistry, { | |
| 40 | run: (_id: EnvironmentId, effect: Effect.Effect<unknown>) => effect, | |
| 41 | } as unknown as EnvironmentRegistry["Service"]), | |
| 42 | ); | |
| 43 | const permissions = createCommandPermissions(runtime, WS_METHODS.scheduledTasksDelete); | |
| 44 | const setup = Effect.gen(function* () { | |
| 45 | const registry = AtomRegistry.make(); | |
| 46 | registry.mount(sessions(env)); | |
| 47 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | |
| 48 | return registry; | |
| 49 | }); | |
| 50 | ||
| 51 | describe("command permissions", () => { | |
| 52 | it.effect("uses the target grant for both availability and execution", () => | |
| 53 | Effect.scoped( | |
| 54 | Effect.gen(function* () { | |
| 55 | const registry = yield* setup; | |
| 56 | registry.set(sessions(env), AsyncResult.success(grant(true))); | |
| 57 | expect(registry.get(permissions.permissionAtom(env))).toBe(true); | |
| 58 | expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env)); | |
| 59 | yield* permissions.authorize(registry, env); | |
| 60 | registry.set(sessions(other), AsyncResult.success(grant(false))); | |
| 61 | expect(registry.get(permissions.permissionAtom(other))).toBe(false); | |
| 62 | const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip); | |
| 63 | expect(denied._tag).toBe("EnvironmentAuthorizationError"); | |
| 64 | }), | |
| 65 | ), | |
| 66 | ); | |
| 67 | it.effect("waits for an initial grant", () => | |
| 68 | Effect.scoped( | |
| 69 | Effect.gen(function* () { | |
| 70 | const registry = yield* setup; | |
| 71 | const fiber = yield* permissions.authorize(registry, env).pipe(Effect.forkChild); | |
| 72 | yield* Effect.yieldNow; | |
| 73 | registry.set(sessions(env), AsyncResult.success(grant(true))); | |
| 74 | yield* Fiber.join(fiber); | |
| 75 | }), | |
| 76 | ), | |
| 77 | ); | |
| 78 | it.effect("bounds a session that never loads", () => | |
| 79 | Effect.scoped( | |
| 80 | Effect.gen(function* () { | |
| 81 | const registry = yield* setup; | |
| 82 | const fiber = yield* permissions | |
| 83 | .authorize(registry, env) | |
| 84 | .pipe(Effect.flip, Effect.forkChild); | |
| 85 | yield* TestClock.adjust("6 seconds"); | |
| 86 | expect((yield* Fiber.join(fiber))._tag).toBe("EnvironmentAuthorizationError"); | |
| 87 | }), | |
| 88 | ), | |
| 89 | ); | |
| 90 | it.effect( | |
| 91 | "denies failed and unauthenticated sessions, but accepts a cached refreshing grant", | |
| 92 | () => | |
| 93 | Effect.scoped( | |
| 94 | Effect.gen(function* () { | |
| 95 | const registry = yield* setup; | |
| 96 | registry.set(sessions(env), AsyncResult.failure(Cause.fail("offline"))); | |
| 97 | expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe( | |
| 98 | "EnvironmentAuthorizationError", | |
| 99 | ); | |
| 100 | registry.set( | |
| 101 | sessions(env), | |
| 102 | AsyncResult.success({ ...grant(true), authenticated: false }), | |
| 103 | ); | |
| 104 | expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe( | |
| 105 | "EnvironmentAuthorizationError", | |
| 106 | ); | |
| 107 | registry.set(sessions(env), AsyncResult.waiting(AsyncResult.success(grant(true)))); | |
| 108 | yield* permissions.authorize(registry, env); | |
| 109 | }), | |
| 110 | ), | |
| 111 | ); | |
| 112 | it("rechecks permission after waiting in a serial command lane", async () => { | |
| 113 | const registry = AtomRegistry.make(); | |
| 114 | const unmount = registry.mount(sessions(env)); | |
| 115 | registry.set(sessions(env), AsyncResult.success(grant(true))); | |
| 116 | let release!: () => void; | |
| 117 | let started!: () => void; | |
| 118 | const entered = new Promise<void>((resolve) => { | |
| 119 | started = resolve; | |
| 120 | }); | |
| 121 | const gate = new Promise<void>((resolve) => { | |
| 122 | release = resolve; | |
| 123 | }); | |
| 124 | let calls = 0; | |
| 125 | const command = createEnvironmentRpcCommand(runtime, { | |
| 126 | label: "test.delete", | |
| 127 | tag: WS_METHODS.scheduledTasksDelete, | |
| 128 | concurrency: { mode: "serial", key: () => "target" }, | |
| 129 | execute: () => | |
| 130 | Effect.promise(async () => { | |
| 131 | calls++; | |
| 132 | started(); | |
| 133 | await gate; | |
| 134 | return { id: ScheduledTaskId.make("task") }; | |
| 135 | }), | |
| 136 | }); | |
| 137 | const target = { environmentId: env, input: { id: ScheduledTaskId.make("task") } }; | |
| 138 | try { | |
| 139 | const first = command.run(registry, target); | |
| 140 | await entered; | |
| 141 | const second = command.run(registry, target); | |
| 142 | registry.set(sessions(env), AsyncResult.success(grant(false))); | |
| 143 | release(); | |
| 144 | expect((await first)._tag).toBe("Success"); | |
| 145 | expect((await second)._tag).toBe("Failure"); | |
| 146 | expect(calls).toBe(1); | |
| 147 | } finally { | |
| 148 | unmount(); | |
| 149 | registry.dispose(); | |
| 150 | } | |
| 151 | }); | |
| 152 | }); |
packages/client-runtime/src/state/commandPermissions.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | import { | |
| 2 | clientRpcRequiredScopes, | |
| 3 | EnvironmentAuthorizationError, | |
| 4 | type EnvironmentId, | |
| 5 | type AuthSessionState, | |
| 6 | type AuthEnvironmentScope, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 11 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 12 | import { createEnvironmentSessionAtoms } from "./session.ts"; | |
| 13 | ||
| 14 | function grants(session: AuthSessionState, scope: AuthEnvironmentScope) { | |
| 15 | return session.authenticated && session.scopes?.includes(scope) === true; | |
| 16 | } | |
| 17 | ||
| 18 | /** UI availability and dispatch use the same target session and method policy. */ | |
| 19 | function makeCommandPermissions<R, E>( | |
| 20 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 21 | method: string, | |
| 22 | ) { | |
| 23 | const sessions = createEnvironmentSessionAtoms(runtime); | |
| 24 | const requiredScopes = (input?: unknown) => clientRpcRequiredScopes(method, input); | |
| 25 | const atomsByEnvironment = Atom.family((environmentId: EnvironmentId | null) => | |
| 26 | Atom.family((scopeKey: string) => | |
| 27 | Atom.make((get) => { | |
| 28 | if (environmentId === null) return false; | |
| 29 | const scopes = scopeKey.split(",").filter(Boolean) as AuthEnvironmentScope[]; | |
| 30 | if (scopes.length === 0) return true; | |
| 31 | const result = get(sessions.sessionStateAtom(environmentId)); | |
| 32 | const session = Option.getOrNull(AsyncResult.value(result)); | |
| 33 | return ( | |
| 34 | result._tag !== "Failure" && | |
| 35 | session !== null && | |
| 36 | scopes.every((scope) => grants(session, scope)) | |
| 37 | ); | |
| 38 | }), | |
| 39 | ), | |
| 40 | ); | |
| 41 | const permissionAtom = (environmentId: EnvironmentId | null, input?: unknown) => | |
| 42 | atomsByEnvironment(environmentId)(requiredScopes(input).join(",")); | |
| 43 | const authorize = ( | |
| 44 | registry: AtomRegistry.AtomRegistry, | |
| 45 | environmentId: EnvironmentId, | |
| 46 | input?: unknown, | |
| 47 | ) => | |
| 48 | Effect.suspend(() => { | |
| 49 | const scopes = requiredScopes(input); | |
| 50 | if (scopes.length === 0) return Effect.void; | |
| 51 | const atom = sessions.sessionStateAtom(environmentId); | |
| 52 | return Effect.scoped( | |
| 53 | Effect.gen(function* () { | |
| 54 | yield* AtomRegistry.mount(registry, atom); | |
| 55 | const session = yield* AtomRegistry.getResult(registry, atom, { | |
| 56 | suspendOnWaiting: false, | |
| 57 | }).pipe( | |
| 58 | Effect.timeoutOption(6_000), | |
| 59 | Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())), | |
| 60 | ); | |
| 61 | const missing = scopes.find( | |
| 62 | (scope) => Option.isNone(session) || !grants(session.value, scope), | |
| 63 | ); | |
| 64 | if (missing !== undefined) | |
| 65 | return yield* Effect.fail( | |
| 66 | new EnvironmentAuthorizationError({ | |
| 67 | requiredScope: missing, | |
| 68 | message: `This connection requires ${missing}.`, | |
| 69 | }), | |
| 70 | ); | |
| 71 | }), | |
| 72 | ); | |
| 73 | }); | |
| 74 | return { requiredScopes, permissionAtom, authorize }; | |
| 75 | } | |
| 76 | ||
| 77 | const permissionsByRuntime = new WeakMap< | |
| 78 | object, | |
| 79 | Map<string, ReturnType<typeof makeCommandPermissions>> | |
| 80 | >(); | |
| 81 | ||
| 82 | /** Reuse availability atoms and dispatch guards across commands on the same runtime. */ | |
| 83 | export function createCommandPermissions<R, E>( | |
| 84 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 85 | method: string, | |
| 86 | ) { | |
| 87 | let permissions = permissionsByRuntime.get(runtime); | |
| 88 | if (permissions === undefined) { | |
| 89 | permissions = new Map(); | |
| 90 | permissionsByRuntime.set(runtime, permissions); | |
| 91 | } | |
| 92 | const existing = permissions.get(method); | |
| 93 | if (existing !== undefined) return existing; | |
| 94 | const created = makeCommandPermissions(runtime, method); | |
| 95 | permissions.set(method, created); | |
| 96 | return created; | |
| 97 | } |
packages/client-runtime/src/state/environmentStreams.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Stream from "effect/Stream"; | |
| 4 | import * as EnvironmentRegistry from "../connection/registry.ts"; | |
| 5 | import type * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 6 | ||
| 7 | export function runStreamInEnvironment<A, E, R>( | |
| 8 | environmentId: EnvironmentIdType, | |
| 9 | stream: Stream.Stream<A, E, R>, | |
| 10 | ): Stream.Stream< | |
| 11 | A, | |
| 12 | E | EnvironmentRegistry.EnvironmentNotRegisteredError, | |
| 13 | EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> | |
| 14 | > { | |
| 15 | return Stream.unwrap( | |
| 16 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 17 | Effect.map((registry) => registry.runStream(environmentId, stream)), | |
| 18 | ), | |
| 19 | ); | |
| 20 | } | |
| 21 | ||
| 22 | export function followStreamInEnvironment<A, E, R>( | |
| 23 | environmentId: EnvironmentIdType, | |
| 24 | stream: Stream.Stream<A, E, R>, | |
| 25 | ): Stream.Stream< | |
| 26 | A, | |
| 27 | E, | |
| 28 | EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> | |
| 29 | > { | |
| 30 | return Stream.unwrap( | |
| 31 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 32 | Effect.map((registry) => registry.followStream(environmentId, stream)), | |
| 33 | ), | |
| 34 | ); | |
| 35 | } |
packages/client-runtime/src/state/pullRequestRouting.ts+1-1| ... | ... | @@ -24,7 +24,7 @@ import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; |
| 24 | 24 | import * as ConnectionProfileStore from "../connection/profileStore.ts"; |
| 25 | 25 | import type { RpcSession } from "../rpc/session.ts"; |
| 26 | 26 | import { |
| 27 | request, | |
| 27 | requestGuarded as request, | |
| 28 | 28 | EnvironmentRpcUnavailableError, |
| 29 | 29 | type EnvironmentRpcInput, |
| 30 | 30 | type EnvironmentUnaryRpcTag, |
packages/client-runtime/src/state/runtime.ts+30-34| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; | |
| 1 | import { createCommandPermissions } from "./commandPermissions.ts"; | |
| 2 | import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; | |
| 3 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; | |
| 4 | import { | |
| 5 | type ClientGuardedRpcTag, | |
| 6 | EnvironmentId, | |
| 7 | type EnvironmentId as EnvironmentIdType, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | import * as Cause from "effect/Cause"; |
| 3 | 10 | import * as Effect from "effect/Effect"; |
| 4 | 11 | import * as Exit from "effect/Exit"; |
| ... | ... | @@ -19,6 +26,8 @@ import { |
| 19 | 26 | type EnvironmentUnaryRpcTag, |
| 20 | 27 | EnvironmentRpcUnavailableError, |
| 21 | 28 | request, |
| 29 | requestGuarded, | |
| 30 | RpcPermissionGuard, | |
| 22 | 31 | subscribe, |
| 23 | 32 | } from "../rpc/client.ts"; |
| 24 | 33 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| ... | ... | @@ -459,36 +468,6 @@ function runInEnvironment<A, E, R>( |
| 459 | 468 | ); |
| 460 | 469 | } |
| 461 | 470 | |
| 462 | export function runStreamInEnvironment<A, E, R>( | |
| 463 | environmentId: EnvironmentIdType, | |
| 464 | stream: Stream.Stream<A, E, R>, | |
| 465 | ): Stream.Stream< | |
| 466 | A, | |
| 467 | E | EnvironmentRegistry.EnvironmentNotRegisteredError, | |
| 468 | EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> | |
| 469 | > { | |
| 470 | return Stream.unwrap( | |
| 471 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 472 | Effect.map((registry) => registry.runStream(environmentId, stream)), | |
| 473 | ), | |
| 474 | ); | |
| 475 | } | |
| 476 | ||
| 477 | export function followStreamInEnvironment<A, E, R>( | |
| 478 | environmentId: EnvironmentIdType, | |
| 479 | stream: Stream.Stream<A, E, R>, | |
| 480 | ): Stream.Stream< | |
| 481 | A, | |
| 482 | E, | |
| 483 | EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> | |
| 484 | > { | |
| 485 | return Stream.unwrap( | |
| 486 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 487 | Effect.map((registry) => registry.followStream(environmentId, stream)), | |
| 488 | ), | |
| 489 | ); | |
| 490 | } | |
| 491 | ||
| 492 | 471 | /** |
| 493 | 472 | * Refreshes a query when `signal` changes, but only while something reads it. |
| 494 | 473 | * `Atom.makeRefreshOnSignal` subscribes to the signal, so a query that outlives its view on an |
| ... | ... | @@ -660,7 +639,11 @@ export function createEnvironmentCommand<R, ER, Input, A, E>( |
| 660 | 639 | }); |
| 661 | 640 | } |
| 662 | 641 | |
| 663 | export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends EnvironmentUnaryRpcTag>( | |
| 642 | export function createEnvironmentRpcQueryAtomFamily< | |
| 643 | R, | |
| 644 | ER, | |
| 645 | TTag extends Exclude<EnvironmentUnaryRpcTag, ClientGuardedRpcTag>, | |
| 646 | >( | |
| 664 | 647 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>, |
| 665 | 648 | options: { |
| 666 | 649 | readonly label: string; |
| ... | ... | @@ -767,7 +750,8 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary |
| 767 | 750 | ) => Effect.Effect<void, never, R>; |
| 768 | 751 | }, |
| 769 | 752 | ) { |
| 770 | return createEnvironmentCommand(runtime, { | |
| 753 | const permissions = createCommandPermissions(runtime, options.tag); | |
| 754 | const command = createEnvironmentCommand(runtime, { | |
| 771 | 755 | label: options.label, |
| 772 | 756 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), |
| 773 | 757 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), |
| ... | ... | @@ -776,10 +760,22 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary |
| 776 | 760 | environmentId, |
| 777 | 761 | input, |
| 778 | 762 | }; |
| 779 | return (options.execute?.(input) ?? request(options.tag, input)).pipe( | |
| 763 | // Routing requires consent on the origin as well as the actual destination. | |
| 764 | // The transport check below deliberately checks the destination again. | |
| 765 | return permissions.authorize(registry, environmentId, input).pipe( | |
| 766 | Effect.andThen(() => options.execute?.(input) ?? requestGuarded(options.tag, input)), | |
| 767 | Effect.provideService(RpcPermissionGuard, { | |
| 768 | authorize: (id, method, payload) => | |
| 769 | createCommandPermissions(runtime, method).authorize(registry, id, payload), | |
| 770 | }), | |
| 780 | 771 | Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void), |
| 781 | 772 | Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void), |
| 782 | 773 | ); |
| 783 | 774 | }, |
| 784 | 775 | }); |
| 776 | return { | |
| 777 | ...command, | |
| 778 | requiredScopes: permissions.requiredScopes, | |
| 779 | permissionAtom: permissions.permissionAtom, | |
| 780 | }; | |
| 785 | 781 | } |
packages/client-runtime/src/state/session.ts+30-5| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; |
| 2 | import * as Data from "effect/Data"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Option from "effect/Option"; |
| 4 | 5 | import * as Stream from "effect/Stream"; |
| 5 | 6 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 6 | import type { HttpClient } from "effect/http"; | |
| 7 | import { HttpClient } from "effect/http"; | |
| 7 | 8 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 8 | 9 | |
| 9 | 10 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| ... | ... | @@ -14,7 +15,12 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 14 | 15 | import * as ManagedRelay from "../relay/managedRelay.ts"; |
| 15 | 16 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 16 | 17 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; |
| 17 | import { followStreamInEnvironment } from "./runtime.ts"; | |
| 18 | import { followStreamInEnvironment } from "./environmentStreams.ts"; | |
| 19 | ||
| 20 | /** @public Required to name the error in consumers' inferred session results. */ | |
| 21 | export class SessionHttpClientUnavailable extends Data.TaggedError( | |
| 22 | "SessionHttpClientUnavailable", | |
| 23 | ) {} | |
| 18 | 24 | |
| 19 | 25 | function initialConfigOption<E>( |
| 20 | 26 | initialConfig: Effect.Effect<ServerConfig, E>, |
| ... | ... | @@ -61,8 +67,8 @@ export const fetchEnvironmentSessionState = Effect.fn( |
| 61 | 67 | }); |
| 62 | 68 | }); |
| 63 | 69 | |
| 64 | export function createEnvironmentSessionAtoms<R, E>( | |
| 65 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient | R, E>, | |
| 70 | function makeEnvironmentSessionAtoms<R, E>( | |
| 71 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>, | |
| 66 | 72 | ) { |
| 67 | 73 | const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => |
| 68 | 74 | runtime.atom( |
| ... | ... | @@ -137,7 +143,13 @@ export function createEnvironmentSessionAtoms<R, E>( |
| 137 | 143 | const remoteAuthorization = yield* Effect.serviceOption( |
| 138 | 144 | RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, |
| 139 | 145 | ); |
| 140 | return yield* fetchEnvironmentSessionState({ prepared, signer, remoteAuthorization }); | |
| 146 | const client = yield* Effect.serviceOption(HttpClient.HttpClient); | |
| 147 | if (Option.isNone(client)) return yield* new SessionHttpClientUnavailable(); | |
| 148 | return yield* fetchEnvironmentSessionState({ | |
| 149 | prepared, | |
| 150 | signer, | |
| 151 | remoteAuthorization, | |
| 152 | }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); | |
| 141 | 153 | }); |
| 142 | 154 | }) |
| 143 | 155 | .pipe( |
| ... | ... | @@ -163,3 +175,16 @@ export function createEnvironmentSessionAtoms<R, E>( |
| 163 | 175 | sessionStateValueAtom, |
| 164 | 176 | }; |
| 165 | 177 | } |
| 178 | ||
| 179 | const sessionAtomsByRuntime = new WeakMap<object, ReturnType<typeof makeEnvironmentSessionAtoms>>(); | |
| 180 | ||
| 181 | /** Commands and UI share one session fetch and the same reconnect invalidation. */ | |
| 182 | export function createEnvironmentSessionAtoms<R, E>( | |
| 183 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>, | |
| 184 | ): ReturnType<typeof makeEnvironmentSessionAtoms<R, E>> { | |
| 185 | const existing = sessionAtomsByRuntime.get(runtime); | |
| 186 | if (existing) return existing as ReturnType<typeof makeEnvironmentSessionAtoms<R, E>>; | |
| 187 | const atoms = makeEnvironmentSessionAtoms(runtime); | |
| 188 | sessionAtomsByRuntime.set(runtime, atoms); | |
| 189 | return atoms; | |
| 190 | } |
packages/client-runtime/src/state/usage.test.ts+2| ... | ... | @@ -75,6 +75,8 @@ function harness(ids = ["a"]) { |
| 75 | 75 | get(environmentId).query, |
| 76 | 76 | refreshUsageRates: { |
| 77 | 77 | label: "test:rates", |
| 78 | requiredScopes: () => [], | |
| 79 | permissionAtom: () => Atom.make(true), | |
| 78 | 80 | run: ( |
| 79 | 81 | _registry: AtomRegistry.AtomRegistry, |
| 80 | 82 | { environmentId }: { environmentId: EnvironmentId }, |
packages/contracts/src/auth.ts+11| ... | ... | @@ -80,6 +80,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; |
| 80 | 80 | |
| 81 | 81 | export const AuthOrchestrationReadScope = "orchestration:read" as const; |
| 82 | 82 | export const AuthOrchestrationOperateScope = "orchestration:operate" as const; |
| 83 | export const AuthSettingsWriteScope = "settings:write" as const; | |
| 84 | export const AuthProvidersManageScope = "providers:manage" as const; | |
| 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; | |
| 83 | 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 84 | 87 | export const AuthReviewWriteScope = "review:write" as const; |
| 85 | 88 | export const AuthAccessReadScope = "access:read" as const; |
| ... | ... | @@ -89,6 +92,9 @@ export const AuthRelayWriteScope = "relay:write" as const; |
| 89 | 92 | export const AuthEnvironmentScope = Schema.Literals([ |
| 90 | 93 | AuthOrchestrationReadScope, |
| 91 | 94 | AuthOrchestrationOperateScope, |
| 95 | AuthSettingsWriteScope, | |
| 96 | AuthProvidersManageScope, | |
| 97 | AuthEnvironmentMaintainScope, | |
| 92 | 98 | AuthTerminalOperateScope, |
| 93 | 99 | AuthReviewWriteScope, |
| 94 | 100 | AuthAccessReadScope, |
| ... | ... | @@ -103,6 +109,9 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; |
| 103 | 109 | export const AuthStandardClientScopes = [ |
| 104 | 110 | AuthOrchestrationReadScope, |
| 105 | 111 | AuthOrchestrationOperateScope, |
| 112 | AuthSettingsWriteScope, | |
| 113 | AuthProvidersManageScope, | |
| 114 | AuthEnvironmentMaintainScope, | |
| 106 | 115 | AuthTerminalOperateScope, |
| 107 | 116 | AuthReviewWriteScope, |
| 108 | 117 | AuthRelayReadScope, |
| ... | ... | @@ -145,6 +154,8 @@ export const ServerAuthDescriptor = Schema.Struct({ |
| 145 | 154 | bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), |
| 146 | 155 | sessionMethods: Schema.Array(ServerAuthSessionMethod), |
| 147 | 156 | sessionCookieName: TrimmedNonEmptyString, |
| 157 | /** Older servers omit this and authorize self-updates with orchestration:operate. */ | |
| 158 | serverUpdateScope: Schema.optionalKey(Schema.Literal(AuthEnvironmentMaintainScope)), | |
| 148 | 159 | }); |
| 149 | 160 | export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; |
| 150 | 161 |
packages/contracts/src/clientRpcPermissions.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import { AuthOrchestrationOperateScope, type AuthEnvironmentScope } from "./auth.ts"; | |
| 2 | import { WS_METHODS } from "./rpc.ts"; | |
| 3 | ||
| 4 | /** Incremental client enforcement; the server still authorizes every request. */ | |
| 5 | export const CLIENT_GUARDED_RPC_SCOPES = { | |
| 6 | [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, | |
| 7 | [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, | |
| 8 | [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, | |
| 9 | [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, | |
| 10 | [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, | |
| 11 | } as const; | |
| 12 | export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; | |
| 13 | ||
| 14 | export function clientRpcRequiredScopes( | |
| 15 | method: string, | |
| 16 | _input: unknown, | |
| 17 | ): readonly AuthEnvironmentScope[] { | |
| 18 | return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method) | |
| 19 | ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]] | |
| 20 | : []; | |
| 21 | } |
packages/contracts/src/index.ts+1| ... | ... | @@ -62,3 +62,4 @@ export * from "./resourceTelemetry.ts"; |
| 62 | 62 | export * from "./rpc.ts"; |
| 63 | 63 | export * from "./worktreeSetup.ts"; |
| 64 | 64 | export * from "./secretRequest.ts"; |
| 65 | export * from "./clientRpcPermissions.ts"; |
packages/contracts/src/settings.ts+25| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | 1 | import { SshDeviceHostConfigs } from "./device.ts"; |
| 2 | import { | |
| 3 | AuthSettingsWriteScope, | |
| 4 | AuthProvidersManageScope, | |
| 5 | type AuthEnvironmentScope, | |
| 6 | } from "./auth.ts"; | |
| 2 | 7 | import * as Effect from "effect/Effect"; |
| 3 | 8 | import * as Duration from "effect/Duration"; |
| 4 | 9 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -1790,6 +1795,26 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1790 | 1795 | }); |
| 1791 | 1796 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1792 | 1797 | |
| 1798 | /** A mixed settings patch must be authorized for every configuration domain it changes. */ | |
| 1799 | export function requiredScopesForServerSettingsPatch( | |
| 1800 | patch: ServerSettingsPatch, | |
| 1801 | ): ReadonlyArray<AuthEnvironmentScope> { | |
| 1802 | let changesProviders = false; | |
| 1803 | let changesSettings = false; | |
| 1804 | for (const [key, value] of Object.entries(patch)) { | |
| 1805 | if (value === undefined) continue; | |
| 1806 | if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { | |
| 1807 | changesProviders = true; | |
| 1808 | } else { | |
| 1809 | changesSettings = true; | |
| 1810 | } | |
| 1811 | } | |
| 1812 | return [ | |
| 1813 | ...(changesSettings || !changesProviders ? [AuthSettingsWriteScope] : []), | |
| 1814 | ...(changesProviders ? [AuthProvidersManageScope] : []), | |
| 1815 | ]; | |
| 1816 | } | |
| 1817 | ||
| 1793 | 1818 | export const ClientSettingsPatch = Schema.Struct({ |
| 1794 | 1819 | notificationMode: Schema.optionalKey(NotificationMode), |
| 1795 | 1820 | inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), |
vite.config.ts+41| ... | ... | @@ -169,12 +169,53 @@ export default defineConfig({ |
| 169 | 169 | "t3code/no-native-title-tooltip": "error", |
| 170 | 170 | "t3code/no-raw-mcp-registration": "error", |
| 171 | 171 | "t3code/no-test-in-loop": "error", |
| 172 | "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }], | |
| 172 | 173 | "t3code/no-unscoped-has": "error", |
| 173 | 174 | "t3code/namespace-node-imports": "error", |
| 174 | 175 | "t3code/prefer-catch-tags": "error", |
| 175 | 176 | "t3code/require-suppression-reason": "error", |
| 176 | 177 | }, |
| 177 | 178 | overrides: [ |
| 179 | { | |
| 180 | files: ["packages/client-runtime/src/state/**", "apps/{web,mobile,desktop}/src/**"], | |
| 181 | rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: false }] }, | |
| 182 | }, | |
| 183 | { | |
| 184 | // Only shared command boundaries install the session-backed permission guard. | |
| 185 | files: [ | |
| 186 | "packages/client-runtime/src/state/runtime.ts", | |
| 187 | "packages/client-runtime/src/state/vcsAction.ts", | |
| 188 | ], | |
| 189 | rules: { | |
| 190 | "t3code/no-rpc-permission-bypass": [ | |
| 191 | "error", | |
| 192 | { allowGuardInstallation: true, allowRawClientAccess: false }, | |
| 193 | ], | |
| 194 | }, | |
| 195 | }, | |
| 196 | { | |
| 197 | // These clients are session metadata, device streams, and an Expo update adapter. | |
| 198 | files: [ | |
| 199 | "apps/web/src/components/settings/ConnectionsSettings.tsx", | |
| 200 | "apps/mobile/src/features/updates/app-updates.ts", | |
| 201 | "apps/web/src/components/device/DevicePhoneViewport.tsx", | |
| 202 | "apps/web/src/components/device/DeviceDuoViewport.tsx", | |
| 203 | ], | |
| 204 | rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }] }, | |
| 205 | }, | |
| 206 | { | |
| 207 | // Incompatible hosts cannot open a normal session; their updater uses a dedicated socket. | |
| 208 | files: ["packages/client-runtime/src/connection/outdatedHostUpdate.ts"], | |
| 209 | rules: { "t3code/no-rpc-permission-bypass": "off" }, | |
| 210 | }, | |
| 211 | { | |
| 212 | // RPC implementation and transport test fixtures need the raw client. | |
| 213 | files: [ | |
| 214 | "packages/client-runtime/src/rpc/**", | |
| 215 | "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", | |
| 216 | ], | |
| 217 | rules: { "t3code/no-rpc-permission-bypass": "off" }, | |
| 218 | }, | |
| 178 | 219 | { |
| 179 | 220 | // The one place that reads the host platform to seed the injected references. |
| 180 | 221 | files: ["packages/shared/src/hostProcess.ts"], |