| 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 | 9 | connectionRoutes, |
| 10 | 10 | isLearned, |
| 11 | 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 | 15 | import * as Option from "effect/Option"; |
| 14 | 16 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 15 | 17 | import { Alert, Platform, Pressable, View } from "react-native"; |
| ... | ... | @@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({ |
| 56 | 58 | }) { |
| 57 | 59 | const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); |
| 58 | 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 | 64 | const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); |
| 60 | 65 | const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); |
| 61 | 66 | const [editing, setEditing] = useState(false); |
| ... | ... | @@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({ |
| 146 | 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 | 184 | {routes.map((route, index) => { |
| 150 | 185 | const id = connectionRouteId(route.target); |
| 151 | 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 | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as Duration from "effect/Duration"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 6 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 7 | 9 | import * as Layer from "effect/Layer"; |
| 8 | 10 | import * as Option from "effect/Option"; |
| ... | ... | @@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect( |
| 113 | 115 | ); |
| 114 | 116 | |
| 115 | 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 | 169 | it.effect("keys remote cookies by environment identity instead of state directory", () => |
| 117 | 170 | Effect.gen(function* () { |
| 118 | 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 | 407 | SessionCredentialInternalError |
| 408 | 408 | >; |
| 409 | 409 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; |
| 410 | readonly awaitInvalidation: ( | |
| 411 | sessionId: AuthSessionId, | |
| 412 | ) => Effect.Effect<void, SessionCredentialVerificationError>; | |
| 410 | 413 | readonly revoke: ( |
| 411 | 414 | sessionId: AuthSessionId, |
| 412 | 415 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; |
| ... | ... | @@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () { |
| 1047 | 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 | 1082 | return SessionStore.of({ |
| 1051 | 1083 | cookieName, |
| 1052 | 1084 | legacyCookieName, |
| ... | ... | @@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () { |
| 1055 | 1087 | issueWebSocketToken, |
| 1056 | 1088 | verifyWebSocketToken, |
| 1057 | 1089 | listActive, |
| 1090 | awaitInvalidation, | |
| 1058 | 1091 | get streamChanges() { |
| 1059 | 1092 | return Stream.fromPubSub(changesPubSub); |
| 1060 | 1093 | }, |
apps/server/src/ws.ts+11-1| ... | ... | @@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap( |
| 3224 | 3224 | ); |
| 3225 | 3225 | return yield* Effect.acquireUseRelease( |
| 3226 | 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 | 3238 | () => sessions.markDisconnected(session.sessionId), |
| 3229 | 3239 | ); |
| 3230 | 3240 | }).pipe( |
apps/web/src/components/settings/ConnectionsSettings.tsx+57-100| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { SessionPermissions } from "./SessionPermissions"; | |
| 2 | import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; | |
| 1 | 3 | import { |
| 2 | 4 | ChevronRightIcon, |
| 3 | 5 | ChevronsLeftRightEllipsisIcon, |
| ... | ... | @@ -23,20 +25,13 @@ import { |
| 23 | 25 | import { |
| 24 | 26 | AuthAccessReadScope, |
| 25 | 27 | AuthAccessWriteScope, |
| 26 | AuthSettingsWriteScope, | |
| 27 | AuthProvidersManageScope, | |
| 28 | 28 | AuthEnvironmentMaintainScope, |
| 29 | 29 | AuthDiagnosticsReadScope, |
| 30 | AuthOrchestrationOperateScope, | |
| 31 | 30 | AuthOrchestrationReadScope, |
| 32 | AuthPreviewOperateScope, | |
| 33 | 31 | AuthRelayReadScope, |
| 34 | 32 | AuthRelayWriteScope, |
| 35 | AuthSourceControlWriteScope, | |
| 36 | 33 | AuthFilesystemReadScope, |
| 37 | AuthFilesystemWriteScope, | |
| 38 | 34 | AuthStandardClientScopes, |
| 39 | AuthTerminalOperateScope, | |
| 40 | 35 | AuthTerminalReadScope, |
| 41 | 36 | type AuthClientSession, |
| 42 | 37 | type AuthEnvironmentScope, |
| ... | ... | @@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string { |
| 231 | 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 | 229 | function AccessScopeSummary({ |
| 322 | 230 | scopes, |
| 323 | 231 | label, |
| ... | ... | @@ -1586,6 +1494,7 @@ function SavedBackendListRow({ |
| 1586 | 1494 | onAddRoute, |
| 1587 | 1495 | }: SavedBackendListRowProps) { |
| 1588 | 1496 | const [routesOpen, setRoutesOpen] = useState(false); |
| 1497 | const [permissionsOpen, setPermissionsOpen] = useState(false); | |
| 1589 | 1498 | const environmentId = environment.environmentId; |
| 1590 | 1499 | const unsupported = environment.connection.phase === "unsupported"; |
| 1591 | 1500 | const enabled = environment.entry.enabled && !unsupported; |
| ... | ... | @@ -1753,6 +1662,25 @@ function SavedBackendListRow({ |
| 1753 | 1662 | )} |
| 1754 | 1663 | /> |
| 1755 | 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 | 1684 | </span> |
| 1757 | 1685 | } |
| 1758 | 1686 | below={ |
| ... | ... | @@ -1763,12 +1691,26 @@ function SavedBackendListRow({ |
| 1763 | 1691 | ) : null |
| 1764 | 1692 | } |
| 1765 | 1693 | detail={ |
| 1766 | routesOpen ? ( | |
| 1767 | <EnvironmentRoutesList | |
| 1768 | environment={environment} | |
| 1769 | onAddRoute={() => onAddRoute(environment)} | |
| 1770 | /> | |
| 1771 | ) : null | |
| 1694 | <> | |
| 1695 | {routesOpen && ( | |
| 1696 | <EnvironmentRoutesList | |
| 1697 | environment={environment} | |
| 1698 | onAddRoute={() => onAddRoute(environment)} | |
| 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 | 1716 | {unsupported && |
| ... | ... | @@ -3774,6 +3716,21 @@ export function ConnectionsSettings() { |
| 3774 | 3716 | ) : null} |
| 3775 | 3717 | </> |
| 3776 | 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 | 3734 | </SettingsSection> |
| 3778 | 3735 | |
| 3779 | 3736 | {canReadAccess || canWriteAccess ? ( |
apps/web/src/components/settings/EnvironmentRoutesList.tsx+13-13| ... | ... | @@ -103,7 +103,7 @@ export function EnvironmentRoutesList({ |
| 103 | 103 | }; |
| 104 | 104 | |
| 105 | 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 | 107 | <DndContext |
| 108 | 108 | sensors={sensors} |
| 109 | 109 | collisionDetection={closestCenter} |
| ... | ... | @@ -128,7 +128,7 @@ export function EnvironmentRoutesList({ |
| 128 | 128 | </ol> |
| 129 | 129 | </SortableContext> |
| 130 | 130 | </DndContext> |
| 131 | <div className="border-t border-border/70 px-1 py-1"> | |
| 131 | <div className="-ml-1.5 pt-1"> | |
| 132 | 132 | <Button size="xs" variant="ghost-muted" onClick={onAddRoute}> |
| 133 | 133 | <PlusIcon className="size-3" /> |
| 134 | 134 | Add route |
| ... | ... | @@ -169,20 +169,10 @@ function SortableRouteRow({ |
| 169 | 169 | ref={setNodeRef} |
| 170 | 170 | style={{ transform: CSS.Translate.toString(transform), transition }} |
| 171 | 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 | 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 | 176 | <div className="min-w-0 flex-1"> |
| 187 | 177 | <p className="flex items-center gap-1.5 text-xs font-medium text-foreground"> |
| 188 | 178 | {label} |
| ... | ... | @@ -199,6 +189,16 @@ function SortableRouteRow({ |
| 199 | 189 | </p> |
| 200 | 190 | ) : null} |
| 201 | 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 | 202 | {removable ? ( |
| 203 | 203 | <Tooltip> |
| 204 | 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 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | EnvironmentAuthorizationError, | |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | ProjectId, |
| 5 | 6 | type ServerSettings, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 8 | 9 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 10 | import * as Cause from "effect/Cause"; | |
| 9 | 11 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 10 | 12 | |
| 11 | 13 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; |
| ... | ... | @@ -410,7 +412,15 @@ describe("scoped settings writes", () => { |
| 410 | 412 | const persistServer = vi |
| 411 | 413 | .fn() |
| 412 | 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 | 424 | .mockRejectedValueOnce(new Error("Disconnected during save")) |
| 415 | 425 | .mockResolvedValueOnce({ _tag: "Success" }); |
| 416 | 426 | const result = await persistScopedSettingsPatch( |
| ... | ... | @@ -419,6 +429,14 @@ describe("scoped settings writes", () => { |
| 419 | 429 | vi.fn(), |
| 420 | 430 | ); |
| 421 | 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 | 440 | expect(result.failedEnvironments.map(({ label }) => label)).toEqual([ |
| 423 | 441 | server.label, |
| 424 | 442 | third.label, |
apps/web/src/components/settings/scopedSettings.ts+25-4| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | type ProjectSettingSource, |
| 20 | 20 | } from "@t3tools/shared/projectSettings"; |
| 21 | 21 | import * as Equal from "effect/Equal"; |
| 22 | import * as Cause from "effect/Cause"; | |
| 22 | 23 | |
| 23 | 24 | import type { ResolvedSettingsScope } from "./settingsScope"; |
| 24 | 25 | |
| ... | ... | @@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch( |
| 394 | 395 | persistServer: (input: { |
| 395 | 396 | environmentId: EnvironmentId; |
| 396 | 397 | input: { patch: ServerSettingsPatch }; |
| 397 | }) => Promise<{ readonly _tag: "Success" | "Failure" }>, | |
| 398 | }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>, | |
| 398 | 399 | persistClient: (patch: ClientSettingsPatch) => void, |
| 399 | 400 | ) { |
| 400 | 401 | if (plan.hasClientWrite) persistClient(plan.clientPatch); |
| ... | ... | @@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch( |
| 403 | 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 | 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 | 430 | return { |
| 411 | 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 | 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; |
| 3 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 3 | import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments"; | |
| 4 | 4 | import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; |
| 5 | 5 | import { InfoIcon, Undo2Icon } from "lucide-react"; |
| 6 | 6 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; |
| ... | ... | @@ -290,6 +290,7 @@ export function SettingsRow({ |
| 290 | 290 | const primarySettingsAvailable = usePrimarySettingsAvailable(); |
| 291 | 291 | const context = useOptionalSettingsScope(); |
| 292 | 292 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 293 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 293 | 294 | const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope); |
| 294 | 295 | const writableIds = useEnvironmentsWithScope( |
| 295 | 296 | context?.connectedEnvironments ?? [], |
| ... | ... | @@ -395,7 +396,14 @@ export function SettingsRow({ |
| 395 | 396 | unavailable && control |
| 396 | 397 | ? inertControl( |
| 397 | 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 | 407 | : context |
| 400 | 408 | ? "Reconnect the selected environment to change this setting." |
| 401 | 409 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, |
apps/web/src/components/settings/useScopedSettings.ts+10-3| ... | ... | @@ -83,20 +83,27 @@ function useRunScopedPlan() { |
| 83 | 83 | Cause.fail( |
| 84 | 84 | new EnvironmentAuthorizationError({ |
| 85 | 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 | 90 | return persistServer(request); |
| 91 | 91 | }, |
| 92 | 92 | persistClientSettingsPatch, |
| 93 | ).then(({ failedEnvironments, savedEnvironmentCount }) => { | |
| 93 | ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => { | |
| 94 | 94 | if (failedEnvironments.length === 0) return; |
| 95 | 95 | toastManager.add({ |
| 96 | 96 | type: "error", |
| 97 | 97 | title: |
| 98 | 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 | 122 | }); |
| 123 | 123 | |
| 124 | 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 | 173 | it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { |
| 126 | 174 | await mountEditor(); |
| 127 | 175 | saveSharedSettings(); |
| ... | ... | @@ -193,7 +241,7 @@ describe("shared settings writes", () => { |
| 193 | 241 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ |
| 194 | 242 | type: "warning", |
| 195 | 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 | 29 | type UnifiedSettings, |
| 30 | 30 | } from "@t3tools/contracts/settings"; |
| 31 | 31 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 32 | import { | |
| 33 | isAtomCommandInterrupted, | |
| 34 | squashAtomCommandFailure, | |
| 35 | } from "@t3tools/client-runtime/state/runtime"; | |
| 32 | 36 | import { |
| 33 | 37 | filterSharedServerPatch, |
| 34 | 38 | splitSharedServerPatch, |
| ... | ... | @@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<E |
| 469 | 473 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 470 | 474 | // Mount this session even on pages without a visible permission-gated control. |
| 471 | 475 | useEnvironmentScope(environmentId, AuthSettingsWriteScope); |
| 472 | const persistServerSettings = useAtomCommand( | |
| 473 | serverEnvironment.updateSettings, | |
| 474 | "server settings update", | |
| 475 | ); | |
| 476 | const persist = useAtomCommand(serverEnvironment.updateSettings, { | |
| 477 | label: "server settings update", | |
| 478 | reportFailure: false, | |
| 479 | }); | |
| 476 | 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 | 499 | const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); |
| 478 | 500 | const updateSettings = useCallback( |
| 479 | 501 | (patch: UnifiedSettingsPatch) => { |
| ... | ... | @@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 488 | 510 | toastManager.add({ |
| 489 | 511 | type: "warning", |
| 490 | 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 | 516 | if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { |
| ... | ... | @@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 518 | 540 | if (environmentId) { |
| 519 | 541 | targets.add(environmentId); |
| 520 | 542 | } |
| 521 | let wroteToTarget = false; | |
| 522 | let permissionDenied = false; | |
| 543 | const writes: Array<Parameters<typeof persist>[0]> = []; | |
| 544 | const deniedLabels: string[] = []; | |
| 523 | 545 | for (const targetId of targets) { |
| 524 | 546 | const target = environments.find((candidate) => candidate.environmentId === targetId); |
| 525 | 547 | const targetPatch = filterSharedServerPatch( |
| ... | ... | @@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 537 | 559 | readEnvironmentScope(targetId, scope), |
| 538 | 560 | ) |
| 539 | 561 | ) { |
| 540 | permissionDenied = true; | |
| 562 | deniedLabels.push(target?.label ?? targetId); | |
| 541 | 563 | continue; |
| 542 | 564 | } |
| 543 | wroteToTarget = true; | |
| 544 | void persistServerSettings({ | |
| 565 | writes.push({ | |
| 545 | 566 | environmentId: targetId, |
| 546 | 567 | input: { patch: targetPatch }, |
| 547 | 568 | }); |
| 548 | 569 | } |
| 549 | if (!wroteToTarget) { | |
| 570 | if (writes.length === 0) { | |
| 550 | 571 | warnUnsaved( |
| 551 | permissionDenied | |
| 552 | ? "This connection does not have permission to change these settings." | |
| 572 | deniedLabels.length > 0 | |
| 573 | ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.` | |
| 553 | 574 | : targets.size > 0 |
| 554 | 575 | ? "Update older servers to save this setting." |
| 555 | 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 | 612 | void persistClientSettingsPatch(clientPatch); |
| 562 | 613 | } |
| 563 | 614 | }, |
| 564 | [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds], | |
| 615 | [environmentId, environments, persist, persistServerSettings, sharedSettingsSyncTargetIds], | |
| 565 | 616 | ); |
| 566 | 617 | |
| 567 | 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 | 91 | choose **Edit**. Signing out of T3 Connect removes only that route; a machine |
| 92 | 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 | 101 | ### Balance new threads across machines |
| 95 | 102 | |
| 96 | 103 | Auto balance is off by default. On web and desktop, enable it in |
packages/shared/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./authScopeOptions": { | |
| 7 | "types": "./src/authScopeOptions.ts", | |
| 8 | "import": "./src/authScopeOptions.ts" | |
| 9 | }, | |
| 6 | 10 | "./orchestrationV2ThreadError": { |
| 7 | 11 | "types": "./src/orchestrationV2ThreadError.ts", |
| 8 | 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 | ]; |