Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions apps/web/src/cloud/linkEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ import {
import { type RpcSession } from "@t3tools/client-runtime/rpc";
import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
import { ManagedRelay } from "@t3tools/client-runtime/relay";
import { CloudSession } from "@t3tools/client-runtime/platform";
import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";

import {
collectCloudLinkTargets,
deregisterRelayEnvironment,
linkPrimaryEnvironmentToCloud,
listManagedCloudEnvironments,
normalizeRelayBaseUrl,
Expand Down Expand Up @@ -122,14 +124,18 @@ function registryLayer(options?: {
}

function services(options?: Parameters<typeof registryLayer>[0]) {
return Layer.mergeAll(relayLayer(), registryLayer(options));
return Layer.mergeAll(
relayLayer(),
registryLayer(options),
Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-token") })),
);
}

function withServices<A, E>(
effect: Effect.Effect<
A,
E,
HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry
HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry | CloudSession
>,
options?: Parameters<typeof registryLayer>[0],
) {
Expand Down Expand Up @@ -436,4 +442,23 @@ describe("web cloud link environment client", () => {
);
}),
);

it.effect("deregisters an inaccessible environment directly through the relay", () =>
Effect.gen(function* () {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ ok: true }));
vi.stubGlobal("fetch", fetchMock);

yield* withServices(
deregisterRelayEnvironment({
environmentId: EnvironmentId.make(TARGET.environmentId),
}),
);

expect(String(fetchMock.mock.calls[0]?.[0])).toContain(
`/v1/client/environment-links/${TARGET.environmentId}`,
);
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE");
expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
}),
);
});
29 changes: 29 additions & 0 deletions apps/web/src/cloud/linkEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
import { request, runStream } from "@t3tools/client-runtime/rpc";
import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
import { ManagedRelay } from "@t3tools/client-runtime/relay";
import { CloudSession } from "@t3tools/client-runtime/platform";

import {
readPrimaryEnvironmentDescriptor,
Expand Down Expand Up @@ -385,6 +386,34 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
}).pipe(Effect.provide(primaryEnvironmentHttpLayer));
}

export function deregisterRelayEnvironment(input: {
readonly environmentId: EnvironmentId;
}): Effect.Effect<void, CloudEnvironmentLinkError, CloudSession | ManagedRelay.ManagedRelayClient> {
return Effect.gen(function* () {
const session = yield* CloudSession;
const clerkToken = yield* session.clerkToken.pipe(
Effect.mapError(
(cause) =>
new CloudEnvironmentLinkError({
message: "Could not obtain the T3 Connect session token.",
cause,
}),
),
);
const relayClient = yield* ManagedRelay.ManagedRelayClient;
yield* relayClient
.unlinkEnvironment({
clerkToken,
environmentId: input.environmentId,
})
.pipe(
Effect.mapError(
decodedRelayClientError("Could not remove the environment from T3 Connect."),
),
);
});
}

// "publish_only" links the environment to the relay for agent-activity
// publishing alone: no managed tunnel is provisioned, so it can be toggled
// independently of T3 Connect while clients reach the environment out of band.
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/cloud/linkEnvironmentAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {

import { connectionAtomRuntime } from "../connection/runtime";
import {
deregisterRelayEnvironment,
linkPrimaryEnvironmentToCloud,
type CloudLinkMode,
type CloudLinkTarget,
Expand Down Expand Up @@ -44,3 +45,13 @@ export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connecti
execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) =>
updatePrimaryCloudPreferences(input),
});

export const deregisterEnvironment = createRuntimeCommand(connectionAtomRuntime, {
label: "web:cloud:deregister-environment",
scheduler: cloudLinkScheduler,
concurrency: {
mode: "serial",
key: (input: { readonly environmentId: string }) => input.environmentId,
},
execute: deregisterRelayEnvironment,
});
113 changes: 106 additions & 7 deletions apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,23 @@ import * as Option from "effect/Option";
import { type ReactNode, useCallback, useEffect, useState } from "react";

import { environmentCatalog } from "~/connection/catalog";
import { deregisterEnvironment as deregisterEnvironmentAtom } from "~/cloud/linkEnvironmentAtoms";
import { cn } from "~/lib/utils";
import { relayEnvironmentDiscovery } from "~/state/relay";
import { useRelayEnvironmentDiscovery } from "~/state/environments";
import { useAtomCommand } from "~/state/use-atom-command";
import { ConnectionStatusDot } from "../ConnectionStatusDot";
import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
import {
AlertDialog,
AlertDialogClose,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogPopup,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import { Skeleton } from "../ui/skeleton";
import { toastManager } from "../ui/toast";
Expand Down Expand Up @@ -66,6 +77,9 @@ export function CloudEnvironmentConnectRows({
const registerEnvironment = useAtomCommand(environmentCatalog.register, {
reportFailure: false,
});
const deregisterEnvironment = useAtomCommand(deregisterEnvironmentAtom, {
reportFailure: false,
});
const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
reportFailure: false,
});
Expand All @@ -84,6 +98,8 @@ export function CloudEnvironmentConnectRows({
const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
null,
);
const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] =
useState<EnvironmentId | null>(null);
const savedById = new Map(
savedEnvironments.map((environment) => [environment.environmentId, environment]),
);
Expand Down Expand Up @@ -127,6 +143,45 @@ export function CloudEnvironmentConnectRows({
});
};

const deregisterRelayEnvironment = async (environment: RelayClientEnvironmentRecord) => {
setDeregisteringEnvironmentId(environment.environmentId);
const result = await deregisterEnvironment({ environmentId: environment.environmentId });
setDeregisteringEnvironmentId(null);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (result._tag === "Success") {
await refreshRelayEnvironments();
toastManager.add({
type: "success",
title: "Server deregistered",
description: "Its T3 Connect access was revoked and one host space is now available.",
});
return;
}
if (isAtomCommandInterrupted(result)) return;
const cause = squashAtomCommandFailure(result);
const message =
cause instanceof Error ? cause.message : "Could not deregister the T3 Connect server.";
const traceId = findErrorTraceId(cause);
console.error("[t3-connect] Could not deregister environment", {
environmentId: environment.environmentId,
message,
traceId,
cause,
});
toastManager.add({
type: "error",
title: "Could not deregister server",
description: message,
data: traceId
? {
secondaryActionProps: {
children: "Copy trace ID",
onClick: () => void navigator.clipboard?.writeText(traceId),
},
}
: undefined,
});
};

const visibleEnvironments = [...environmentsState.environments.values()].filter(
({ environment }) =>
environment.environmentId !== primaryEnvironmentId &&
Expand Down Expand Up @@ -245,13 +300,57 @@ export function CloudEnvironmentConnectRows({
{savedConnection.buttonLabel}
</Button>
) : (
<Button
size="sm"
disabled={connectingEnvironmentId !== null}
onClick={() => void connectEnvironment(environment)}
>
{connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}
</Button>
<div className="flex items-center gap-2">
<AlertDialog>
<AlertDialogTrigger
render={
<Button
size="sm"
variant="destructive-outline"
disabled={
connectingEnvironmentId !== null || deregisteringEnvironmentId !== null
}
>
{deregisteringEnvironmentId === environment.environmentId
? "Deregistering…"
: "Deregister"}
</Button>
}
/>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>
Deregister “{environment.label}” from T3 Connect?
</AlertDialogTitle>
<AlertDialogDescription>
This revokes this server’s T3 Connect access, removes its managed tunnel, and
frees one of your three host spaces. You can use this even if you no longer
have access to the server.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline">Cancel</Button>} />
<AlertDialogClose
render={
<Button
variant="destructive"
onClick={() => void deregisterRelayEnvironment(environment)}
>
Deregister server
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
<Button
size="sm"
disabled={connectingEnvironmentId !== null || deregisteringEnvironmentId !== null}
onClick={() => void connectEnvironment(environment)}
>
{connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}
</Button>
</div>
)}
</div>
</div>
Expand Down
Loading
Loading