| author | |
| committer | |
| log | 456930a09db29efdd3d4a397fe214e9ede2d6975 |
| tree | 2ef0b4e6c2ab60bbe81a131372a0e058f1235a71 |
| parent | 2c7a446e657e705c257db28540819f4c0115b2fc |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Bear Huddleston <bear@bearhuddleston.dev>16 files changed, 536 insertions(+), 140 deletions(-)
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+36-1| ... | @@ -9,7 +9,9 @@ import { | ... | @@ -9,7 +9,9 @@ import { |
| 9 | connectionRoutes, | 9 | connectionRoutes, |
| 10 | isLearned, | 10 | isLearned, |
| 11 | } from "@t3tools/client-runtime/connection"; | 11 | } from "@t3tools/client-runtime/connection"; |
| 12 | import type { EnvironmentId } from "@t3tools/contracts"; | 12 | import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; |
| 13 | import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; | ||
| 14 | import { AsyncResult } from "effect/reactivity"; | ||
| 13 | import * as Option from "effect/Option"; | 15 | import * as Option from "effect/Option"; |
| 14 | import { useEffect, useMemo, useRef, useState } from "react"; | 16 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 15 | import { Alert, Platform, Pressable, View } from "react-native"; | 17 | import { Alert, Platform, Pressable, View } from "react-native"; |
| ... | @@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({ | ... | @@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({ |
| 56 | }) { | 58 | }) { |
| 57 | const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); | 59 | const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); |
| 58 | const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); | 60 | const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); |
| 61 | const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); | ||
| 62 | const session = Option.getOrNull(AsyncResult.value(sessionResult)); | ||
| 63 | const [permissionsOpen, setPermissionsOpen] = useState(false); | ||
| 59 | const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); | 64 | const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); |
| 60 | const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); | 65 | const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); |
| 61 | const [editing, setEditing] = useState(false); | 66 | const [editing, setEditing] = useState(false); |
| ... | @@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({ | ... | @@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({ |
| 146 | ) : undefined | 151 | ) : undefined |
| 147 | } | 152 | } |
| 148 | > | 153 | > |
| 154 | <SettingsActionRow | ||
| 155 | icon="checkmark.circle" | ||
| 156 | label="Your permissions" | ||
| 157 | onPress={() => setPermissionsOpen((open) => !open)} | ||
| 158 | /> | ||
| 159 | {permissionsOpen ? ( | ||
| 160 | <View className="gap-2 px-4 py-3"> | ||
| 161 | {activeRouteId !== null && | ||
| 162 | sessionResult._tag !== "Failure" && | ||
| 163 | !sessionResult.waiting && | ||
| 164 | session?.authenticated ? ( | ||
| 165 | <> | ||
| 166 | <Text className="text-sm text-foreground-muted"> | ||
| 167 | Applies to the route marked In use. Other routes may have different permissions and | ||
| 168 | have not been checked. | ||
| 169 | </Text> | ||
| 170 | {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => ( | ||
| 171 | <Text key={scope} className="text-sm text-foreground"> | ||
| 172 | {title}: {sessionGrantsScope(session, scope) ? "Allowed" : "Not granted"} | ||
| 173 | </Text> | ||
| 174 | ))} | ||
| 175 | </> | ||
| 176 | ) : ( | ||
| 177 | <Text className="text-sm text-foreground-muted"> | ||
| 178 | Permissions not checked. Connect to this environment to view this session’s | ||
| 179 | permissions. | ||
| 180 | </Text> | ||
| 181 | )} | ||
| 182 | </View> | ||
| 183 | ) : null} | ||
| 149 | {routes.map((route, index) => { | 184 | {routes.map((route, index) => { |
| 150 | const id = connectionRouteId(route.target); | 185 | const id = connectionRouteId(route.target); |
| 151 | // Rows between the lifted row and its drop slot shift to make room. | 186 | // Rows between the lifted row and its drop slot shift to make room. |
apps/server/src/auth/SessionStore.test.ts+53| ... | @@ -3,6 +3,8 @@ import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | ... | @@ -3,6 +3,8 @@ import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { expect, it } from "@effect/vitest"; | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | import * as Duration from "effect/Duration"; | 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| ... | @@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect( | ... | @@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect( |
| 113 | ); | 115 | ); |
| 114 | 116 | ||
| 115 | it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | 117 | it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 118 | it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)( | ||
| 119 | "invalidates every connection for a session on %s and leaves other sessions active", | ||
| 120 | (operation) => | ||
| 121 | Effect.gen(function* () { | ||
| 122 | const sessions = yield* SessionStore.SessionStore; | ||
| 123 | const owner = yield* sessions.issue({ subject: "owner" }); | ||
| 124 | const client = yield* sessions.issue(relaySessionInput); | ||
| 125 | const ownerInvalidated = yield* Deferred.make<void>(); | ||
| 126 | yield* sessions | ||
| 127 | .awaitInvalidation(owner.sessionId) | ||
| 128 | .pipe( | ||
| 129 | Effect.andThen(Deferred.succeed(ownerInvalidated, undefined)), | ||
| 130 | Effect.forkScoped({ startImmediately: true }), | ||
| 131 | ); | ||
| 132 | const first = yield* sessions | ||
| 133 | .awaitInvalidation(client.sessionId) | ||
| 134 | .pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 135 | const second = yield* sessions | ||
| 136 | .awaitInvalidation(client.sessionId) | ||
| 137 | .pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 138 | if (operation === "revoke") yield* sessions.revoke(client.sessionId); | ||
| 139 | else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId); | ||
| 140 | else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId }); | ||
| 141 | yield* Fiber.join(first); | ||
| 142 | yield* Fiber.join(second); | ||
| 143 | expect(yield* Deferred.isDone(ownerInvalidated)).toBe(false); | ||
| 144 | // A connection whose upgrade authenticated just before revocation must also stop. | ||
| 145 | yield* sessions.awaitInvalidation(client.sessionId); | ||
| 146 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("expires a connected session at its credential deadline", () => | ||
| 150 | Effect.gen(function* () { | ||
| 151 | const sessions = yield* SessionStore.SessionStore; | ||
| 152 | const client = yield* sessions.issue(relaySessionInput); | ||
| 153 | yield* sessions.markConnected(client.sessionId); | ||
| 154 | const invalidated = yield* Deferred.make<void>(); | ||
| 155 | const watcher = yield* sessions | ||
| 156 | .awaitInvalidation(client.sessionId) | ||
| 157 | .pipe( | ||
| 158 | Effect.andThen(Deferred.succeed(invalidated, undefined)), | ||
| 159 | Effect.forkScoped({ startImmediately: true }), | ||
| 160 | ); | ||
| 161 | yield* TestClock.adjust(Duration.minutes(59)); | ||
| 162 | expect(yield* Deferred.isDone(invalidated)).toBe(false); | ||
| 163 | yield* TestClock.adjust(Duration.minutes(1)); | ||
| 164 | yield* Fiber.join(watcher); | ||
| 165 | yield* sessions.awaitInvalidation(client.sessionId); | ||
| 166 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), | ||
| 167 | ); | ||
| 168 | |||
| 116 | it.effect("keys remote cookies by environment identity instead of state directory", () => | 169 | it.effect("keys remote cookies by environment identity instead of state directory", () => |
| 117 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 118 | const cookieName = (stateDir: string, environmentId: EnvironmentId) => | 171 | const cookieName = (stateDir: string, environmentId: EnvironmentId) => |
apps/server/src/auth/SessionStore.ts+33| ... | @@ -407,6 +407,9 @@ export class SessionStore extends Context.Service< | ... | @@ -407,6 +407,9 @@ export class SessionStore extends Context.Service< |
| 407 | SessionCredentialInternalError | 407 | SessionCredentialInternalError |
| 408 | >; | 408 | >; |
| 409 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; | 409 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; |
| 410 | readonly awaitInvalidation: ( | ||
| 411 | sessionId: AuthSessionId, | ||
| 412 | ) => Effect.Effect<void, SessionCredentialVerificationError>; | ||
| 410 | readonly revoke: ( | 413 | readonly revoke: ( |
| 411 | sessionId: AuthSessionId, | 414 | sessionId: AuthSessionId, |
| 412 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; | 415 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; |
| ... | @@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () { | ... | @@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () { |
| 1047 | return revokedSessionIds.length; | 1050 | return revokedSessionIds.length; |
| 1048 | }); | 1051 | }); |
| 1049 | 1052 | ||
| 1053 | const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) { | ||
| 1054 | return yield* Effect.scoped( | ||
| 1055 | Effect.gen(function* () { | ||
| 1056 | // Subscribe before reading: revocation can race with the WebSocket upgrade. | ||
| 1057 | const subscription = yield* PubSub.subscribe(changesPubSub); | ||
| 1058 | const row = yield* authSessions | ||
| 1059 | .getById({ sessionId }) | ||
| 1060 | .pipe( | ||
| 1061 | Effect.mapError( | ||
| 1062 | (cause) => new SessionCredentialVerificationError({ sessionId, cause }), | ||
| 1063 | ), | ||
| 1064 | ); | ||
| 1065 | if (Option.isNone(row) || row.value.revokedAt !== null) return; | ||
| 1066 | const now = yield* DateTime.now; | ||
| 1067 | const remaining = row.value.expiresAt.epochMilliseconds - now.epochMilliseconds; | ||
| 1068 | if (remaining <= 0) return; | ||
| 1069 | yield* Effect.raceFirst( | ||
| 1070 | Stream.fromSubscription(subscription).pipe( | ||
| 1071 | Stream.filter( | ||
| 1072 | (change) => change.type === "clientRemoved" && change.sessionId === sessionId, | ||
| 1073 | ), | ||
| 1074 | Stream.runHead, | ||
| 1075 | ), | ||
| 1076 | Effect.sleep(Duration.millis(remaining)), | ||
| 1077 | ); | ||
| 1078 | }), | ||
| 1079 | ); | ||
| 1080 | }); | ||
| 1081 | |||
| 1050 | return SessionStore.of({ | 1082 | return SessionStore.of({ |
| 1051 | cookieName, | 1083 | cookieName, |
| 1052 | legacyCookieName, | 1084 | legacyCookieName, |
| ... | @@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () { |
| 1055 | issueWebSocketToken, | 1087 | issueWebSocketToken, |
| 1056 | verifyWebSocketToken, | 1088 | verifyWebSocketToken, |
| 1057 | listActive, | 1089 | listActive, |
| 1090 | awaitInvalidation, | ||
| 1058 | get streamChanges() { | 1091 | get streamChanges() { |
| 1059 | return Stream.fromPubSub(changesPubSub); | 1092 | return Stream.fromPubSub(changesPubSub); |
| 1060 | }, | 1093 | }, |
apps/server/src/ws.ts+11-1| ... | @@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap( | ... | @@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap( |
| 3224 | ); | 3224 | ); |
| 3225 | return yield* Effect.acquireUseRelease( | 3225 | return yield* Effect.acquireUseRelease( |
| 3226 | sessions.markConnected(session.sessionId), | 3226 | sessions.markConnected(session.sessionId), |
| 3227 | () => rpcWebSocketHttpEffect, | 3227 | () => |
| 3228 | Effect.raceFirst( | ||
| 3229 | rpcWebSocketHttpEffect, | ||
| 3230 | sessions.awaitInvalidation(session.sessionId).pipe( | ||
| 3231 | Effect.as(HttpServerResponse.empty()), | ||
| 3232 | Effect.catchTags({ | ||
| 3233 | SessionCredentialVerificationError: (error) => | ||
| 3234 | failEnvironmentInternal("internal_error", error), | ||
| 3235 | }), | ||
| 3236 | ), | ||
| 3237 | ), | ||
| 3228 | () => sessions.markDisconnected(session.sessionId), | 3238 | () => sessions.markDisconnected(session.sessionId), |
| 3229 | ); | 3239 | ); |
| 3230 | }).pipe( | 3240 | }).pipe( |
apps/web/src/components/settings/ConnectionsSettings.tsx+57-100| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { SessionPermissions } from "./SessionPermissions"; | ||
| 2 | import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; | ||
| 1 | import { | 3 | import { |
| 2 | ChevronRightIcon, | 4 | ChevronRightIcon, |
| 3 | ChevronsLeftRightEllipsisIcon, | 5 | ChevronsLeftRightEllipsisIcon, |
| ... | @@ -23,20 +25,13 @@ import { | ... | @@ -23,20 +25,13 @@ import { |
| 23 | import { | 25 | import { |
| 24 | AuthAccessReadScope, | 26 | AuthAccessReadScope, |
| 25 | AuthAccessWriteScope, | 27 | AuthAccessWriteScope, |
| 26 | AuthSettingsWriteScope, | ||
| 27 | AuthProvidersManageScope, | ||
| 28 | AuthEnvironmentMaintainScope, | 28 | AuthEnvironmentMaintainScope, |
| 29 | AuthDiagnosticsReadScope, | 29 | AuthDiagnosticsReadScope, |
| 30 | AuthOrchestrationOperateScope, | ||
| 31 | AuthOrchestrationReadScope, | 30 | AuthOrchestrationReadScope, |
| 32 | AuthPreviewOperateScope, | ||
| 33 | AuthRelayReadScope, | 31 | AuthRelayReadScope, |
| 34 | AuthRelayWriteScope, | 32 | AuthRelayWriteScope, |
| 35 | AuthSourceControlWriteScope, | ||
| 36 | AuthFilesystemReadScope, | 33 | AuthFilesystemReadScope, |
| 37 | AuthFilesystemWriteScope, | ||
| 38 | AuthStandardClientScopes, | 34 | AuthStandardClientScopes, |
| 39 | AuthTerminalOperateScope, | ||
| 40 | AuthTerminalReadScope, | 35 | AuthTerminalReadScope, |
| 41 | type AuthClientSession, | 36 | type AuthClientSession, |
| 42 | type AuthEnvironmentScope, | 37 | type AuthEnvironmentScope, |
| ... | @@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string { | ... | @@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string { |
| 231 | return accessTimestampFormatter.format(parsed); | 226 | return accessTimestampFormatter.format(parsed); |
| 232 | } | 227 | } |
| 233 | 228 | ||
| 234 | const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ | ||
| 235 | readonly scope: AuthGrantScope; | ||
| 236 | readonly title: string; | ||
| 237 | readonly description: string; | ||
| 238 | }> = [ | ||
| 239 | { | ||
| 240 | scope: AuthOrchestrationReadScope, | ||
| 241 | title: "View environment", | ||
| 242 | description: "Read threads, status, checkpoints, and configuration.", | ||
| 243 | }, | ||
| 244 | { | ||
| 245 | scope: AuthOrchestrationOperateScope, | ||
| 246 | title: "Operate tasks", | ||
| 247 | description: "Start, update, and stop tasks.", | ||
| 248 | }, | ||
| 249 | { | ||
| 250 | scope: AuthSettingsWriteScope, | ||
| 251 | title: "Change environment settings", | ||
| 252 | description: "Edit environment preferences and keybindings.", | ||
| 253 | }, | ||
| 254 | { | ||
| 255 | scope: AuthProvidersManageScope, | ||
| 256 | title: "Manage providers", | ||
| 257 | description: "Configure, install, sign in to, and update providers and usage sources.", | ||
| 258 | }, | ||
| 259 | { | ||
| 260 | scope: AuthEnvironmentMaintainScope, | ||
| 261 | title: "Maintain environment", | ||
| 262 | description: "Update the server and control environment processes.", | ||
| 263 | }, | ||
| 264 | { | ||
| 265 | scope: AuthPreviewOperateScope, | ||
| 266 | title: "Control previews", | ||
| 267 | description: "Open browser previews and host browser automation.", | ||
| 268 | }, | ||
| 269 | { | ||
| 270 | scope: AuthDiagnosticsReadScope, | ||
| 271 | title: "View diagnostics and usage", | ||
| 272 | description: "Read process diagnostics, resource history, and usage totals.", | ||
| 273 | }, | ||
| 274 | { | ||
| 275 | scope: AuthTerminalReadScope, | ||
| 276 | title: "View terminals", | ||
| 277 | description: "Read existing terminal output and status.", | ||
| 278 | }, | ||
| 279 | { | ||
| 280 | scope: AuthTerminalOperateScope, | ||
| 281 | title: "Use terminals", | ||
| 282 | description: "Create terminals and send input to running shells.", | ||
| 283 | }, | ||
| 284 | { | ||
| 285 | scope: AuthSourceControlWriteScope, | ||
| 286 | title: "Change source control", | ||
| 287 | description: "Commit, push, manage branches and repositories, and change pull requests.", | ||
| 288 | }, | ||
| 289 | { | ||
| 290 | scope: AuthFilesystemReadScope, | ||
| 291 | title: "Read files", | ||
| 292 | description: "Browse host files, search workspaces, and inspect local changes.", | ||
| 293 | }, | ||
| 294 | { | ||
| 295 | scope: AuthFilesystemWriteScope, | ||
| 296 | title: "Write files", | ||
| 297 | description: "Edit workspace files and save plans to disk.", | ||
| 298 | }, | ||
| 299 | { | ||
| 300 | scope: AuthAccessReadScope, | ||
| 301 | title: "View access", | ||
| 302 | description: "Inspect pairing links and authorized clients.", | ||
| 303 | }, | ||
| 304 | { | ||
| 305 | scope: AuthAccessWriteScope, | ||
| 306 | title: "Manage access", | ||
| 307 | description: "Issue and revoke credentials for other clients.", | ||
| 308 | }, | ||
| 309 | { | ||
| 310 | scope: AuthRelayReadScope, | ||
| 311 | title: "View relay", | ||
| 312 | description: "Inspect managed relay connectivity.", | ||
| 313 | }, | ||
| 314 | { | ||
| 315 | scope: AuthRelayWriteScope, | ||
| 316 | title: "Manage relay", | ||
| 317 | description: "Change managed tunnel connectivity.", | ||
| 318 | }, | ||
| 319 | ]; | ||
| 320 | |||
| 321 | function AccessScopeSummary({ | 229 | function AccessScopeSummary({ |
| 322 | scopes, | 230 | scopes, |
| 323 | label, | 231 | label, |
| ... | @@ -1586,6 +1494,7 @@ function SavedBackendListRow({ | ... | @@ -1586,6 +1494,7 @@ function SavedBackendListRow({ |
| 1586 | onAddRoute, | 1494 | onAddRoute, |
| 1587 | }: SavedBackendListRowProps) { | 1495 | }: SavedBackendListRowProps) { |
| 1588 | const [routesOpen, setRoutesOpen] = useState(false); | 1496 | const [routesOpen, setRoutesOpen] = useState(false); |
| 1497 | const [permissionsOpen, setPermissionsOpen] = useState(false); | ||
| 1589 | const environmentId = environment.environmentId; | 1498 | const environmentId = environment.environmentId; |
| 1590 | const unsupported = environment.connection.phase === "unsupported"; | 1499 | const unsupported = environment.connection.phase === "unsupported"; |
| 1591 | const enabled = environment.entry.enabled && !unsupported; | 1500 | const enabled = environment.entry.enabled && !unsupported; |
| ... | @@ -1753,6 +1662,25 @@ function SavedBackendListRow({ | ... | @@ -1753,6 +1662,25 @@ function SavedBackendListRow({ |
| 1753 | )} | 1662 | )} |
| 1754 | /> | 1663 | /> |
| 1755 | </button> | 1664 | </button> |
| 1665 | <span aria-hidden className="shrink-0"> | ||
| 1666 | · | ||
| 1667 | </span> | ||
| 1668 | <button | ||
| 1669 | type="button" | ||
| 1670 | aria-expanded={permissionsOpen} | ||
| 1671 | aria-controls={`remote-permissions-${environmentId}`} | ||
| 1672 | onClick={() => setPermissionsOpen((open) => !open)} | ||
| 1673 | className="inline-flex shrink-0 items-center gap-0.5 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring" | ||
| 1674 | > | ||
| 1675 | Permissions | ||
| 1676 | <ChevronRightIcon | ||
| 1677 | aria-hidden | ||
| 1678 | className={cn( | ||
| 1679 | "size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none", | ||
| 1680 | permissionsOpen && "rotate-90", | ||
| 1681 | )} | ||
| 1682 | /> | ||
| 1683 | </button> | ||
| 1756 | </span> | 1684 | </span> |
| 1757 | } | 1685 | } |
| 1758 | below={ | 1686 | below={ |
| ... | @@ -1763,12 +1691,26 @@ function SavedBackendListRow({ | ... | @@ -1763,12 +1691,26 @@ function SavedBackendListRow({ |
| 1763 | ) : null | 1691 | ) : null |
| 1764 | } | 1692 | } |
| 1765 | detail={ | 1693 | detail={ |
| 1766 | routesOpen ? ( | 1694 | <> |
| 1767 | <EnvironmentRoutesList | 1695 | {routesOpen && ( |
| 1768 | environment={environment} | 1696 | <EnvironmentRoutesList |
| 1769 | onAddRoute={() => onAddRoute(environment)} | 1697 | environment={environment} |
| 1770 | /> | 1698 | onAddRoute={() => onAddRoute(environment)} |
| 1771 | ) : null | 1699 | /> |
| 1700 | )} | ||
| 1701 | {permissionsOpen && ( | ||
| 1702 | <div | ||
| 1703 | id={`remote-permissions-${environmentId}`} | ||
| 1704 | className="mt-2 border-t border-border/50" | ||
| 1705 | > | ||
| 1706 | <SessionPermissions | ||
| 1707 | environmentId={environmentId} | ||
| 1708 | connected={isConnected} | ||
| 1709 | routeContext | ||
| 1710 | /> | ||
| 1711 | </div> | ||
| 1712 | )} | ||
| 1713 | </> | ||
| 1772 | } | 1714 | } |
| 1773 | > | 1715 | > |
| 1774 | {unsupported && | 1716 | {unsupported && |
| ... | @@ -3774,6 +3716,21 @@ export function ConnectionsSettings() { | ... | @@ -3774,6 +3716,21 @@ export function ConnectionsSettings() { |
| 3774 | ) : null} | 3716 | ) : null} |
| 3775 | </> | 3717 | </> |
| 3776 | ) : null} | 3718 | ) : null} |
| 3719 | {primaryEnvironment ? ( | ||
| 3720 | <details className="group px-3 sm:px-4"> | ||
| 3721 | <summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 rounded-md text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden"> | ||
| 3722 | Permissions | ||
| 3723 | <ChevronRightIcon | ||
| 3724 | aria-hidden | ||
| 3725 | className="size-3 text-muted-foreground group-open:rotate-90" | ||
| 3726 | /> | ||
| 3727 | </summary> | ||
| 3728 | <SessionPermissions | ||
| 3729 | environmentId={primaryEnvironment.environmentId} | ||
| 3730 | connected={primaryEnvironment.connection.phase === "connected"} | ||
| 3731 | /> | ||
| 3732 | </details> | ||
| 3733 | ) : null} | ||
| 3777 | </SettingsSection> | 3734 | </SettingsSection> |
| 3778 | 3735 | ||
| 3779 | {canReadAccess || canWriteAccess ? ( | 3736 | {canReadAccess || canWriteAccess ? ( |
apps/web/src/components/settings/EnvironmentRoutesList.tsx+13-13| ... | @@ -103,7 +103,7 @@ export function EnvironmentRoutesList({ | ... | @@ -103,7 +103,7 @@ export function EnvironmentRoutesList({ |
| 103 | }; | 103 | }; |
| 104 | 104 | ||
| 105 | return ( | 105 | return ( |
| 106 | <div className="mt-2 rounded-md border border-border/70"> | 106 | <div className="mt-2 border-t border-border/50 py-2"> |
| 107 | <DndContext | 107 | <DndContext |
| 108 | sensors={sensors} | 108 | sensors={sensors} |
| 109 | collisionDetection={closestCenter} | 109 | collisionDetection={closestCenter} |
| ... | @@ -128,7 +128,7 @@ export function EnvironmentRoutesList({ | ... | @@ -128,7 +128,7 @@ export function EnvironmentRoutesList({ |
| 128 | </ol> | 128 | </ol> |
| 129 | </SortableContext> | 129 | </SortableContext> |
| 130 | </DndContext> | 130 | </DndContext> |
| 131 | <div className="border-t border-border/70 px-1 py-1"> | 131 | <div className="-ml-1.5 pt-1"> |
| 132 | <Button size="xs" variant="ghost-muted" onClick={onAddRoute}> | 132 | <Button size="xs" variant="ghost-muted" onClick={onAddRoute}> |
| 133 | <PlusIcon className="size-3" /> | 133 | <PlusIcon className="size-3" /> |
| 134 | Add route | 134 | Add route |
| ... | @@ -169,20 +169,10 @@ function SortableRouteRow({ | ... | @@ -169,20 +169,10 @@ function SortableRouteRow({ |
| 169 | ref={setNodeRef} | 169 | ref={setNodeRef} |
| 170 | style={{ transform: CSS.Translate.toString(transform), transition }} | 170 | style={{ transform: CSS.Translate.toString(transform), transition }} |
| 171 | className={cn( | 171 | className={cn( |
| 172 | "flex items-center gap-2 border-b border-border/70 px-1 py-1.5 last:border-b-0", | 172 | "flex items-center gap-2 py-2", |
| 173 | isDragging && "relative z-10 rounded-md bg-background shadow-md", | 173 | isDragging && "relative z-10 rounded-md bg-background shadow-md", |
| 174 | )} | 174 | )} |
| 175 | > | 175 | > |
| 176 | <button | ||
| 177 | type="button" | ||
| 178 | ref={setActivatorNodeRef} | ||
| 179 | {...attributes} | ||
| 180 | {...listeners} | ||
| 181 | aria-label={`Reorder ${label}, position ${position}`} | ||
| 182 | className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing" | ||
| 183 | > | ||
| 184 | <GripVerticalIcon className="size-3.5" /> | ||
| 185 | </button> | ||
| 186 | <div className="min-w-0 flex-1"> | 176 | <div className="min-w-0 flex-1"> |
| 187 | <p className="flex items-center gap-1.5 text-xs font-medium text-foreground"> | 177 | <p className="flex items-center gap-1.5 text-xs font-medium text-foreground"> |
| 188 | {label} | 178 | {label} |
| ... | @@ -199,6 +189,16 @@ function SortableRouteRow({ | ... | @@ -199,6 +189,16 @@ function SortableRouteRow({ |
| 199 | </p> | 189 | </p> |
| 200 | ) : null} | 190 | ) : null} |
| 201 | </div> | 191 | </div> |
| 192 | <button | ||
| 193 | type="button" | ||
| 194 | ref={setActivatorNodeRef} | ||
| 195 | {...attributes} | ||
| 196 | {...listeners} | ||
| 197 | aria-label={`Reorder ${label}, position ${position}`} | ||
| 198 | className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing" | ||
| 199 | > | ||
| 200 | <GripVerticalIcon className="size-3.5" /> | ||
| 201 | </button> | ||
| 202 | {removable ? ( | 202 | {removable ? ( |
| 203 | <Tooltip> | 203 | <Tooltip> |
| 204 | <TooltipTrigger | 204 | <TooltipTrigger |
apps/web/src/components/settings/SessionPermissions.tsx created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; | ||
| 2 | import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; | ||
| 3 | import { useEnvironmentSessionState } from "~/state/session"; | ||
| 4 | |||
| 5 | export function SessionPermissions({ | ||
| 6 | environmentId, | ||
| 7 | connected, | ||
| 8 | routeContext = false, | ||
| 9 | }: { | ||
| 10 | readonly environmentId: EnvironmentId; | ||
| 11 | readonly connected: boolean; | ||
| 12 | readonly routeContext?: boolean; | ||
| 13 | }) { | ||
| 14 | const session = useEnvironmentSessionState(environmentId); | ||
| 15 | return connected && !session.hasError && !session.isPending && session.data?.authenticated ? ( | ||
| 16 | <div className="space-y-3 py-3 text-xs"> | ||
| 17 | <p className="text-muted-foreground"> | ||
| 18 | {routeContext | ||
| 19 | ? "These permissions apply to your active route. Other routes may have different permissions." | ||
| 20 | : "These permissions apply to this client’s current connection."} | ||
| 21 | </p> | ||
| 22 | <ul className="grid gap-x-6 gap-y-2 sm:grid-cols-2"> | ||
| 23 | {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => ( | ||
| 24 | <li key={scope} className="flex items-start justify-between gap-3"> | ||
| 25 | <span>{title}</span> | ||
| 26 | <span className="shrink-0 text-muted-foreground"> | ||
| 27 | {sessionGrantsScope(session.data!, scope) ? "Allowed" : "Not granted"} | ||
| 28 | </span> | ||
| 29 | </li> | ||
| 30 | ))} | ||
| 31 | </ul> | ||
| 32 | </div> | ||
| 33 | ) : ( | ||
| 34 | <p className="py-3 text-xs text-muted-foreground"> | ||
| 35 | Permissions not checked. Connect to this environment to view this session’s permissions. | ||
| 36 | </p> | ||
| 37 | ); | ||
| 38 | } | ||
apps/web/src/components/settings/scopedSettings.test.ts+19-1| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | EnvironmentAuthorizationError, | ||
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | ProjectId, | 5 | ProjectId, |
| 5 | type ServerSettings, | 6 | type ServerSettings, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it, vi } from "vite-plus/test"; | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 8 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 9 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 10 | import * as Cause from "effect/Cause"; | ||
| 9 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 11 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 10 | 12 | ||
| 11 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | 13 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; |
| ... | @@ -410,7 +412,15 @@ describe("scoped settings writes", () => { | ... | @@ -410,7 +412,15 @@ describe("scoped settings writes", () => { |
| 410 | const persistServer = vi | 412 | const persistServer = vi |
| 411 | .fn() | 413 | .fn() |
| 412 | .mockResolvedValueOnce({ _tag: "Success" }) | 414 | .mockResolvedValueOnce({ _tag: "Success" }) |
| 413 | .mockResolvedValueOnce({ _tag: "Failure" }) | 415 | .mockResolvedValueOnce({ |
| 416 | _tag: "Failure", | ||
| 417 | cause: Cause.fail( | ||
| 418 | new EnvironmentAuthorizationError({ | ||
| 419 | requiredScope: "settings:write", | ||
| 420 | message: "This connection lacks permission to change settings.", | ||
| 421 | }), | ||
| 422 | ), | ||
| 423 | }) | ||
| 414 | .mockRejectedValueOnce(new Error("Disconnected during save")) | 424 | .mockRejectedValueOnce(new Error("Disconnected during save")) |
| 415 | .mockResolvedValueOnce({ _tag: "Success" }); | 425 | .mockResolvedValueOnce({ _tag: "Success" }); |
| 416 | const result = await persistScopedSettingsPatch( | 426 | const result = await persistScopedSettingsPatch( |
| ... | @@ -419,6 +429,14 @@ describe("scoped settings writes", () => { | ... | @@ -419,6 +429,14 @@ describe("scoped settings writes", () => { |
| 419 | vi.fn(), | 429 | vi.fn(), |
| 420 | ); | 430 | ); |
| 421 | expect(result.savedEnvironmentCount).toBe(2); | 431 | expect(result.savedEnvironmentCount).toBe(2); |
| 432 | expect(result.savedEnvironments.map(({ label }) => label)).toEqual([ | ||
| 433 | laptop.label, | ||
| 434 | fourth.label, | ||
| 435 | ]); | ||
| 436 | expect(result.failedEnvironments.map(({ message }) => message)).toEqual([ | ||
| 437 | "This connection lacks permission to change settings.", | ||
| 438 | "Disconnected during save", | ||
| 439 | ]); | ||
| 422 | expect(result.failedEnvironments.map(({ label }) => label)).toEqual([ | 440 | expect(result.failedEnvironments.map(({ label }) => label)).toEqual([ |
| 423 | server.label, | 441 | server.label, |
| 424 | third.label, | 442 | third.label, |
apps/web/src/components/settings/scopedSettings.ts+25-4| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | type ProjectSettingSource, | 19 | type ProjectSettingSource, |
| 20 | } from "@t3tools/shared/projectSettings"; | 20 | } from "@t3tools/shared/projectSettings"; |
| 21 | import * as Equal from "effect/Equal"; | 21 | import * as Equal from "effect/Equal"; |
| 22 | import * as Cause from "effect/Cause"; | ||
| 22 | 23 | ||
| 23 | import type { ResolvedSettingsScope } from "./settingsScope"; | 24 | import type { ResolvedSettingsScope } from "./settingsScope"; |
| 24 | 25 | ||
| ... | @@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch( | ... | @@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch( |
| 394 | persistServer: (input: { | 395 | persistServer: (input: { |
| 395 | environmentId: EnvironmentId; | 396 | environmentId: EnvironmentId; |
| 396 | input: { patch: ServerSettingsPatch }; | 397 | input: { patch: ServerSettingsPatch }; |
| 397 | }) => Promise<{ readonly _tag: "Success" | "Failure" }>, | 398 | }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>, |
| 398 | persistClient: (patch: ClientSettingsPatch) => void, | 399 | persistClient: (patch: ClientSettingsPatch) => void, |
| 399 | ) { | 400 | ) { |
| 400 | if (plan.hasClientWrite) persistClient(plan.clientPatch); | 401 | if (plan.hasClientWrite) persistClient(plan.clientPatch); |
| ... | @@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch( | ... | @@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch( |
| 403 | persistServer({ environmentId, input: { patch } }), | 404 | persistServer({ environmentId, input: { patch } }), |
| 404 | ), | 405 | ), |
| 405 | ); | 406 | ); |
| 406 | const failedEnvironments = plan.serverWrites.filter((_, index) => { | 407 | const failedEnvironments = plan.serverWrites.flatMap((environment, index) => { |
| 407 | const result = results[index]; | 408 | const result = results[index]; |
| 408 | return result?.status !== "fulfilled" || result.value._tag === "Failure"; | 409 | if (result?.status === "fulfilled" && result.value._tag === "Success") return []; |
| 410 | const error: unknown = | ||
| 411 | result?.status === "rejected" | ||
| 412 | ? result.reason | ||
| 413 | : result?.status === "fulfilled" && result.value.cause | ||
| 414 | ? Cause.squash(result.value.cause) | ||
| 415 | : undefined; | ||
| 416 | return [ | ||
| 417 | { | ||
| 418 | ...environment, | ||
| 419 | message: | ||
| 420 | error instanceof Error | ||
| 421 | ? error.message | ||
| 422 | : "The save failed. Try reconnecting and saving again.", | ||
| 423 | }, | ||
| 424 | ]; | ||
| 425 | }); | ||
| 426 | const savedEnvironments = plan.serverWrites.filter((_, index) => { | ||
| 427 | const result = results[index]; | ||
| 428 | return result?.status === "fulfilled" && result.value._tag === "Success"; | ||
| 409 | }); | 429 | }); |
| 410 | return { | 430 | return { |
| 411 | failedEnvironments, | 431 | failedEnvironments, |
| 412 | savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length, | 432 | savedEnvironments, |
| 433 | savedEnvironmentCount: savedEnvironments.length, | ||
| 413 | }; | 434 | }; |
| 414 | } | 435 | } |
apps/web/src/components/settings/settingsLayout.tsx+10-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { SettingsGroup } from "./SettingsGroup"; | 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; | 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; |
| 3 | import { usePrimaryEnvironmentId } from "../../state/environments"; | 3 | import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments"; |
| 4 | import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; | 4 | import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; |
| 5 | import { InfoIcon, Undo2Icon } from "lucide-react"; | 5 | import { InfoIcon, Undo2Icon } from "lucide-react"; |
| 6 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; | 6 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; |
| ... | @@ -290,6 +290,7 @@ export function SettingsRow({ | ... | @@ -290,6 +290,7 @@ export function SettingsRow({ |
| 290 | const primarySettingsAvailable = usePrimarySettingsAvailable(); | 290 | const primarySettingsAvailable = usePrimarySettingsAvailable(); |
| 291 | const context = useOptionalSettingsScope(); | 291 | const context = useOptionalSettingsScope(); |
| 292 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 292 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 293 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 293 | const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope); | 294 | const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope); |
| 294 | const writableIds = useEnvironmentsWithScope( | 295 | const writableIds = useEnvironmentsWithScope( |
| 295 | context?.connectedEnvironments ?? [], | 296 | context?.connectedEnvironments ?? [], |
| ... | @@ -395,7 +396,14 @@ export function SettingsRow({ | ... | @@ -395,7 +396,14 @@ export function SettingsRow({ |
| 395 | unavailable && control | 396 | unavailable && control |
| 396 | ? inertControl( | 397 | ? inertControl( |
| 397 | !canWriteSettings | 398 | !canWriteSettings |
| 398 | ? "This connection does not have permission to change environment settings." | 399 | ? `This connection lacks permission to change settings on ${ |
| 400 | context | ||
| 401 | ? context.connectedEnvironments | ||
| 402 | .filter((target) => !writableIds.has(target.environmentId)) | ||
| 403 | .map((target) => target.label) | ||
| 404 | .join(", ") || "the selected environment" | ||
| 405 | : (primaryEnvironment?.label ?? "the primary environment") | ||
| 406 | }.` | ||
| 399 | : context | 407 | : context |
| 400 | ? "Reconnect the selected environment to change this setting." | 408 | ? "Reconnect the selected environment to change this setting." |
| 401 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, | 409 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, |
apps/web/src/components/settings/useScopedSettings.ts+10-3| ... | @@ -83,20 +83,27 @@ function useRunScopedPlan() { | ... | @@ -83,20 +83,27 @@ function useRunScopedPlan() { |
| 83 | Cause.fail( | 83 | Cause.fail( |
| 84 | new EnvironmentAuthorizationError({ | 84 | new EnvironmentAuthorizationError({ |
| 85 | requiredScope: missing, | 85 | requiredScope: missing, |
| 86 | message: "This connection cannot change these settings.", | 86 | message: "This connection lacks permission to change these settings.", |
| 87 | }), | 87 | }), |
| 88 | ), | 88 | ), |
| 89 | ); | 89 | ); |
| 90 | return persistServer(request); | 90 | return persistServer(request); |
| 91 | }, | 91 | }, |
| 92 | persistClientSettingsPatch, | 92 | persistClientSettingsPatch, |
| 93 | ).then(({ failedEnvironments, savedEnvironmentCount }) => { | 93 | ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => { |
| 94 | if (failedEnvironments.length === 0) return; | 94 | if (failedEnvironments.length === 0) return; |
| 95 | toastManager.add({ | 95 | toastManager.add({ |
| 96 | type: "error", | 96 | type: "error", |
| 97 | title: | 97 | title: |
| 98 | savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved", | 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." : ""}`, | 99 | description: [ |
| 100 | ...failedEnvironments.map( | ||
| 101 | ({ label, message }) => `Could not save on ${label}: ${message}`, | ||
| 102 | ), | ||
| 103 | ...(savedEnvironmentCount > 0 | ||
| 104 | ? [`Saved on ${savedEnvironments.map(({ label }) => label).join(", ")}.`] | ||
| 105 | : []), | ||
| 106 | ].join("\n"), | ||
| 100 | }); | 107 | }); |
| 101 | }); | 108 | }); |
| 102 | }, | 109 | }, |
apps/web/src/hooks/useSettings.sync.test.tsx+49-1| ... | @@ -122,6 +122,54 @@ afterEach(async () => { | ... | @@ -122,6 +122,54 @@ afterEach(async () => { |
| 122 | }); | 122 | }); |
| 123 | 123 | ||
| 124 | describe("shared settings writes", () => { | 124 | describe("shared settings writes", () => { |
| 125 | it("waits for the remaining target before reporting a partial save", async () => { | ||
| 126 | let finishRemote = () => {}; | ||
| 127 | const remote = new Promise((resolve) => { | ||
| 128 | finishRemote = () => resolve(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); | ||
| 129 | }); | ||
| 130 | state.persist.mockResolvedValueOnce( | ||
| 131 | AsyncResult.failure(Cause.fail(new Error("Permission denied"))), | ||
| 132 | ); | ||
| 133 | state.persist.mockReturnValueOnce(remote); | ||
| 134 | await mountEditor(); | ||
| 135 | await act(async () => saveSharedSettings()); | ||
| 136 | expect(state.toast).not.toHaveBeenCalled(); | ||
| 137 | await act(async () => finishRemote()); | ||
| 138 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ | ||
| 139 | type: "error", | ||
| 140 | title: "Setting saved on some environments", | ||
| 141 | description: "Could not save on primary: Permission denied\nSaved on remote.", | ||
| 142 | }); | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("keeps a single-target failure specific to that environment", async () => { | ||
| 146 | state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); | ||
| 147 | state.persist.mockResolvedValueOnce( | ||
| 148 | AsyncResult.failure(Cause.fail(new Error("Permission denied"))), | ||
| 149 | ); | ||
| 150 | await mountEditor(); | ||
| 151 | await act(async () => saveSharedSettings()); | ||
| 152 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ | ||
| 153 | type: "error", | ||
| 154 | title: "Setting not saved", | ||
| 155 | description: "Could not save on primary: Permission denied", | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("names the failed environment when a server rejects a save after the grant check", async () => { | ||
| 160 | state.persist.mockResolvedValueOnce(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); | ||
| 161 | state.persist.mockResolvedValueOnce( | ||
| 162 | AsyncResult.failure(Cause.fail(new Error("Permission denied"))), | ||
| 163 | ); | ||
| 164 | await mountEditor(); | ||
| 165 | await act(async () => saveSharedSettings()); | ||
| 166 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ | ||
| 167 | type: "error", | ||
| 168 | title: "Setting saved on some environments", | ||
| 169 | description: "Could not save on remote: Permission denied\nSaved on primary.", | ||
| 170 | }); | ||
| 171 | }); | ||
| 172 | |||
| 125 | it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { | 173 | it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { |
| 126 | await mountEditor(); | 174 | await mountEditor(); |
| 127 | saveSharedSettings(); | 175 | saveSharedSettings(); |
| ... | @@ -193,7 +241,7 @@ describe("shared settings writes", () => { | ... | @@ -193,7 +241,7 @@ describe("shared settings writes", () => { |
| 193 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ | 241 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ |
| 194 | type: "warning", | 242 | type: "warning", |
| 195 | title: "Setting not saved", | 243 | title: "Setting not saved", |
| 196 | description: "This connection does not have permission to change these settings.", | 244 | description: "This connection lacks permission to change settings on remote.", |
| 197 | }); | 245 | }); |
| 198 | } | 246 | } |
| 199 | }, | 247 | }, |
apps/web/src/hooks/useSettings.ts+65-14| ... | @@ -29,6 +29,10 @@ import { | ... | @@ -29,6 +29,10 @@ import { |
| 29 | type UnifiedSettings, | 29 | type UnifiedSettings, |
| 30 | } from "@t3tools/contracts/settings"; | 30 | } from "@t3tools/contracts/settings"; |
| 31 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 31 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 32 | import { | ||
| 33 | isAtomCommandInterrupted, | ||
| 34 | squashAtomCommandFailure, | ||
| 35 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 32 | import { | 36 | import { |
| 33 | filterSharedServerPatch, | 37 | filterSharedServerPatch, |
| 34 | splitSharedServerPatch, | 38 | splitSharedServerPatch, |
| ... | @@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E | ... | @@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E |
| 469 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | 473 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 470 | // Mount this session even on pages without a visible permission-gated control. | 474 | // Mount this session even on pages without a visible permission-gated control. |
| 471 | useEnvironmentScope(environmentId, AuthSettingsWriteScope); | 475 | useEnvironmentScope(environmentId, AuthSettingsWriteScope); |
| 472 | const persistServerSettings = useAtomCommand( | 476 | const persist = useAtomCommand(serverEnvironment.updateSettings, { |
| 473 | serverEnvironment.updateSettings, | 477 | label: "server settings update", |
| 474 | "server settings update", | 478 | reportFailure: false, |
| 475 | ); | 479 | }); |
| 476 | const { environments } = useEnvironments(); | 480 | const { environments } = useEnvironments(); |
| 481 | const persistServerSettings = useCallback( | ||
| 482 | async (request: Parameters<typeof persist>[0]) => { | ||
| 483 | const result = await persist(request); | ||
| 484 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 485 | const label = | ||
| 486 | environments.find((environment) => environment.environmentId === request.environmentId) | ||
| 487 | ?.label ?? request.environmentId; | ||
| 488 | const error = squashAtomCommandFailure(result); | ||
| 489 | toastManager.add({ | ||
| 490 | type: "error", | ||
| 491 | title: "Setting not saved", | ||
| 492 | description: `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`, | ||
| 493 | }); | ||
| 494 | } | ||
| 495 | return result; | ||
| 496 | }, | ||
| 497 | [environments, persist], | ||
| 498 | ); | ||
| 477 | const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); | 499 | const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); |
| 478 | const updateSettings = useCallback( | 500 | const updateSettings = useCallback( |
| 479 | (patch: UnifiedSettingsPatch) => { | 501 | (patch: UnifiedSettingsPatch) => { |
| ... | @@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 488 | toastManager.add({ | 510 | toastManager.add({ |
| 489 | type: "warning", | 511 | type: "warning", |
| 490 | title: "Setting not saved", | 512 | title: "Setting not saved", |
| 491 | description: "This connection does not have permission to change these settings.", | 513 | description: `This connection lacks permission to change settings on ${environments.find((target) => target.environmentId === environmentId)?.label ?? "the selected environment"}.`, |
| 492 | }); | 514 | }); |
| 493 | } | 515 | } |
| 494 | if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { | 516 | if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { |
| ... | @@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 518 | if (environmentId) { | 540 | if (environmentId) { |
| 519 | targets.add(environmentId); | 541 | targets.add(environmentId); |
| 520 | } | 542 | } |
| 521 | let wroteToTarget = false; | 543 | const writes: Array<Parameters<typeof persist>[0]> = []; |
| 522 | let permissionDenied = false; | 544 | const deniedLabels: string[] = []; |
| 523 | for (const targetId of targets) { | 545 | for (const targetId of targets) { |
| 524 | const target = environments.find((candidate) => candidate.environmentId === targetId); | 546 | const target = environments.find((candidate) => candidate.environmentId === targetId); |
| 525 | const targetPatch = filterSharedServerPatch( | 547 | const targetPatch = filterSharedServerPatch( |
| ... | @@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 537 | readEnvironmentScope(targetId, scope), | 559 | readEnvironmentScope(targetId, scope), |
| 538 | ) | 560 | ) |
| 539 | ) { | 561 | ) { |
| 540 | permissionDenied = true; | 562 | deniedLabels.push(target?.label ?? targetId); |
| 541 | continue; | 563 | continue; |
| 542 | } | 564 | } |
| 543 | wroteToTarget = true; | 565 | writes.push({ |
| 544 | void persistServerSettings({ | ||
| 545 | environmentId: targetId, | 566 | environmentId: targetId, |
| 546 | input: { patch: targetPatch }, | 567 | input: { patch: targetPatch }, |
| 547 | }); | 568 | }); |
| 548 | } | 569 | } |
| 549 | if (!wroteToTarget) { | 570 | if (writes.length === 0) { |
| 550 | warnUnsaved( | 571 | warnUnsaved( |
| 551 | permissionDenied | 572 | deniedLabels.length > 0 |
| 552 | ? "This connection does not have permission to change these settings." | 573 | ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.` |
| 553 | : targets.size > 0 | 574 | : targets.size > 0 |
| 554 | ? "Update older servers to save this setting." | 575 | ? "Update older servers to save this setting." |
| 555 | : undefined, | 576 | : undefined, |
| 556 | ); | 577 | ); |
| 578 | } else { | ||
| 579 | void Promise.all( | ||
| 580 | writes.map(async (request) => ({ | ||
| 581 | label: | ||
| 582 | environments.find((target) => target.environmentId === request.environmentId) | ||
| 583 | ?.label ?? request.environmentId, | ||
| 584 | result: await persist(request), | ||
| 585 | })), | ||
| 586 | ).then((outcomes) => { | ||
| 587 | const failures = outcomes.flatMap(({ label, result }) => { | ||
| 588 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; | ||
| 589 | const error = squashAtomCommandFailure(result); | ||
| 590 | return [ | ||
| 591 | `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`, | ||
| 592 | ]; | ||
| 593 | }); | ||
| 594 | if (failures.length === 0) return; | ||
| 595 | const saved = outcomes | ||
| 596 | .filter(({ result }) => result._tag === "Success") | ||
| 597 | .map(({ label }) => label); | ||
| 598 | toastManager.add({ | ||
| 599 | type: "error", | ||
| 600 | title: | ||
| 601 | saved.length > 0 ? "Setting saved on some environments" : "Setting not saved", | ||
| 602 | description: [ | ||
| 603 | ...failures, | ||
| 604 | ...(saved.length > 0 ? [`Saved on ${saved.join(", ")}.`] : []), | ||
| 605 | ].join("\n"), | ||
| 606 | }); | ||
| 607 | }); | ||
| 557 | } | 608 | } |
| 558 | } | 609 | } |
| 559 | } | 610 | } |
| ... | @@ -561,7 +612,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -561,7 +612,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 561 | void persistClientSettingsPatch(clientPatch); | 612 | void persistClientSettingsPatch(clientPatch); |
| 562 | } | 613 | } |
| 563 | }, | 614 | }, |
| 564 | [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds], | 615 | [environmentId, environments, persist, persistServerSettings, sharedSettingsSyncTargetIds], |
| 565 | ); | 616 | ); |
| 566 | 617 | ||
| 567 | return updateSettings; | 618 | return updateSettings; |
docs/user/remote-access.md+7| ... | @@ -91,6 +91,13 @@ or remove it. On mobile, open the machine under **Settings → Environments** an | ... | @@ -91,6 +91,13 @@ or remove it. On mobile, open the machine under **Settings → Environments** an |
| 91 | choose **Edit**. Signing out of T3 Connect removes only that route; a machine | 91 | choose **Edit**. Signing out of T3 Connect removes only that route; a machine |
| 92 | you can still reach another way stays saved. | 92 | you can still reach another way stays saved. |
| 93 | 93 | ||
| 94 | Open **Permissions** next to **Routes** in web or desktop, or **Your permissions** | ||
| 95 | in the mobile route details, to see what your current connection can do on that | ||
| 96 | environment. For a remote environment, this is in its route details. Permissions | ||
| 97 | shown there apply only to the route marked **In use**; other routes are not | ||
| 98 | checked. Direct pairing and T3 Connect have separate sessions and may grant | ||
| 99 | different permissions. | ||
| 100 | |||
| 94 | ### Balance new threads across machines | 101 | ### Balance new threads across machines |
| 95 | 102 | ||
| 96 | Auto balance is off by default. On web and desktop, enable it in | 103 | Auto balance is off by default. On web and desktop, enable it in |
packages/shared/package.json+4| ... | @@ -3,6 +3,10 @@ | ... | @@ -3,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./authScopeOptions": { | ||
| 7 | "types": "./src/authScopeOptions.ts", | ||
| 8 | "import": "./src/authScopeOptions.ts" | ||
| 9 | }, | ||
| 6 | "./orchestrationV2ThreadError": { | 10 | "./orchestrationV2ThreadError": { |
| 7 | "types": "./src/orchestrationV2ThreadError.ts", | 11 | "types": "./src/orchestrationV2ThreadError.ts", |
| 8 | "import": "./src/orchestrationV2ThreadError.ts" | 12 | "import": "./src/orchestrationV2ThreadError.ts" |
packages/shared/src/authScopeOptions.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import { | ||
| 2 | AuthAccessReadScope, | ||
| 3 | AuthAccessWriteScope, | ||
| 4 | AuthDiagnosticsReadScope, | ||
| 5 | AuthEnvironmentMaintainScope, | ||
| 6 | AuthFilesystemReadScope, | ||
| 7 | AuthFilesystemWriteScope, | ||
| 8 | type AuthGrantScope, | ||
| 9 | AuthOrchestrationOperateScope, | ||
| 10 | AuthOrchestrationReadScope, | ||
| 11 | AuthPreviewOperateScope, | ||
| 12 | AuthProvidersManageScope, | ||
| 13 | AuthRelayReadScope, | ||
| 14 | AuthRelayWriteScope, | ||
| 15 | AuthSettingsWriteScope, | ||
| 16 | AuthSourceControlWriteScope, | ||
| 17 | AuthTerminalOperateScope, | ||
| 18 | AuthTerminalReadScope, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | |||
| 21 | export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{ | ||
| 22 | readonly scope: AuthGrantScope; | ||
| 23 | readonly title: string; | ||
| 24 | readonly description: string; | ||
| 25 | }> = [ | ||
| 26 | { | ||
| 27 | scope: AuthOrchestrationReadScope, | ||
| 28 | title: "View environment", | ||
| 29 | description: "Read threads, status, checkpoints, and configuration.", | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | scope: AuthOrchestrationOperateScope, | ||
| 33 | title: "Operate tasks", | ||
| 34 | description: "Start, update, and stop tasks.", | ||
| 35 | }, | ||
| 36 | { | ||
| 37 | scope: AuthSettingsWriteScope, | ||
| 38 | title: "Change environment settings", | ||
| 39 | description: "Edit environment preferences and keybindings.", | ||
| 40 | }, | ||
| 41 | { | ||
| 42 | scope: AuthProvidersManageScope, | ||
| 43 | title: "Manage providers", | ||
| 44 | description: "Configure, install, sign in to, and update providers and usage sources.", | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | scope: AuthEnvironmentMaintainScope, | ||
| 48 | title: "Maintain environment", | ||
| 49 | description: "Update the server and control environment processes.", | ||
| 50 | }, | ||
| 51 | { | ||
| 52 | scope: AuthPreviewOperateScope, | ||
| 53 | title: "Control previews", | ||
| 54 | description: "Open browser previews and host browser automation.", | ||
| 55 | }, | ||
| 56 | { | ||
| 57 | scope: AuthDiagnosticsReadScope, | ||
| 58 | title: "View diagnostics and usage", | ||
| 59 | description: "Read process diagnostics, resource history, and usage totals.", | ||
| 60 | }, | ||
| 61 | { | ||
| 62 | scope: AuthTerminalReadScope, | ||
| 63 | title: "View terminals", | ||
| 64 | description: "Read existing terminal output and status.", | ||
| 65 | }, | ||
| 66 | { | ||
| 67 | scope: AuthTerminalOperateScope, | ||
| 68 | title: "Use terminals", | ||
| 69 | description: "Create terminals and send input to running shells.", | ||
| 70 | }, | ||
| 71 | { | ||
| 72 | scope: AuthSourceControlWriteScope, | ||
| 73 | title: "Change source control", | ||
| 74 | description: "Commit, push, manage branches and repositories, and change pull requests.", | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | scope: AuthFilesystemReadScope, | ||
| 78 | title: "Read files", | ||
| 79 | description: "Browse host files, search workspaces, and inspect local changes.", | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | scope: AuthFilesystemWriteScope, | ||
| 83 | title: "Write files", | ||
| 84 | description: "Edit workspace files and save plans to disk.", | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | scope: AuthAccessReadScope, | ||
| 88 | title: "View access", | ||
| 89 | description: "Inspect pairing links and authorized clients.", | ||
| 90 | }, | ||
| 91 | { | ||
| 92 | scope: AuthAccessWriteScope, | ||
| 93 | title: "Manage access", | ||
| 94 | description: "Issue and revoke credentials for other clients.", | ||
| 95 | }, | ||
| 96 | { | ||
| 97 | scope: AuthRelayReadScope, | ||
| 98 | title: "View relay", | ||
| 99 | description: "Inspect managed relay connectivity.", | ||
| 100 | }, | ||
| 101 | { | ||
| 102 | scope: AuthRelayWriteScope, | ||
| 103 | title: "Manage relay", | ||
| 104 | description: "Change managed tunnel connectivity.", | ||
| 105 | }, | ||
| 106 | ]; | ||