diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 5f29c121dbcd..9a56bde88514 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,14 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, } from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime( export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "web:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx new file mode 100644 index 000000000000..09021aaad51c --- /dev/null +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -0,0 +1,86 @@ +import { RefreshCwIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; + +export function ClerkUserProfilePage({ + action, + children, + className, + description, + title, +}: { + readonly action?: ReactNode; + readonly children: ReactNode; + readonly className?: string; + readonly description?: ReactNode; + readonly title: ReactNode; +}) { + return ( +
+
+
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {action ?
{action}
: null} +
+ + {children} +
+ ); +} + +export function ClerkUserProfileRefreshButton({ + className, + disabled = false, + isPending, + onClick, +}: { + readonly className?: string; + readonly disabled?: boolean; + readonly isPending: boolean; + readonly onClick: () => void; +}) { + return ( + + ); +} + +export function ClerkUserProfileRow({ + children, + className, + icon, +}: { + readonly children: ReactNode; + readonly className?: string; + readonly icon: ReactNode; +}) { + return ( +
  • +
    + +
    {children}
    +
    +
  • + ); +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx index 26af10ba5b83..22449c336742 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -1,8 +1,7 @@ import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; -import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; +import { SmartphoneIcon } from "lucide-react"; import { useManagedRelayDevices } from "../../cloud/managedRelayState"; -import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; @@ -12,6 +11,11 @@ import { mobileClientPlatformLabel, mobileClientUpdatedAtLabel, } from "./MobileClientsUserProfilePage.logic"; +import { + ClerkUserProfilePage, + ClerkUserProfileRefreshButton, + ClerkUserProfileRow, +} from "./ClerkUserProfilePage"; const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; @@ -31,53 +35,47 @@ function MobileClientStatusBadge({ function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { return ( -
  • -
    -
    - -
    -
    -
    -
    -

    {device.label}

    -

    {mobileClientPlatformLabel(device)}

    -
    -

    - {mobileClientUpdatedAtLabel(device.updatedAt)} -

    -
    -
    - - -
    -

    - {mobileClientNotificationDetail(device)} + }> +

    +
    +

    + {device.label} +

    +

    + {mobileClientPlatformLabel(device)}

    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    -
  • +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

    + ); } function MobileClientsSkeleton() { return ( -
    +
    {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( -
    +
    - +
    - + -
    - - +
    + +
    @@ -89,13 +87,13 @@ function MobileClientsSkeleton() { function EmptyMobileClients() { return ( - - + + - No mobile clients - + No mobile clients + Sign in to T3 Code on your iPhone to register it for push notifications and Live Activities. @@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() { const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; return ( -
    -
    -
    -

    Mobile clients

    -

    - Devices registered to receive T3 Connect activity from your environments. -

    -
    - -
    - -
    + /> + } + > +
    {devicesState.error ? (
    @@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() { {isInitialLoad ? ( ) : hasErrorWithoutData ? null : devices.length > 0 ? ( -
      +
        {devices.map((device) => ( ))} @@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() { )}
    -
    + ); } diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index 51ee5aa5b328..9dfd8dce13b1 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,9 +1,10 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon, SmartphoneIcon } from "lucide-react"; +import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; +import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() { > + } + url="t3-connect" + > + + ); } diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx new file mode 100644 index 000000000000..377c9c945559 --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx @@ -0,0 +1,63 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { T3ConnectEnvironmentRow } from "./T3ConnectUserProfilePage"; + +const environment: RelayClientEnvironmentRecord = { + environmentId: "environment-1" as EnvironmentId, + label: "Studio Mac", + endpoint: { + httpBaseUrl: "https://studio.example.com", + wsBaseUrl: "wss://studio.example.com", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-08-12T12:00:00.000Z", +}; + +function renderRow({ + confirmationOpen = false, + mutationPending = false, +}: { + readonly confirmationOpen?: boolean; + readonly mutationPending?: boolean; +} = {}) { + return renderToStaticMarkup( + , + ); +} + +describe("T3 Connect environment row", () => { + it("keeps deregistration confirmation inline and collapsed by default", () => { + const markup = renderRow(); + + expect(markup).toContain("Studio Mac"); + expect(markup).toContain("Deregister"); + expect(markup).not.toContain("Deregister server"); + expect(markup).not.toContain("Confirm deregistration of Studio Mac"); + }); + + it("expands Clerk-style confirmation content beneath the environment row", () => { + const markup = renderRow({ confirmationOpen: true }); + + expect(markup).toContain("Deregister server"); + expect(markup).toContain("“Studio Mac” will be removed from this account."); + expect(markup).toContain("Confirm deregistration of Studio Mac"); + expect(markup).toContain("Local connections on your devices are not changed."); + expect(markup).toContain("Cancel"); + }); + + it("locks the confirmation actions while deregistration is pending", () => { + const markup = renderRow({ confirmationOpen: true, mutationPending: true }); + + expect(markup).toContain("Deregistering…"); + expect(markup.match(/ disabled=""/g)).toHaveLength(3); + }); +}); diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx new file mode 100644 index 000000000000..15ed569052be --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -0,0 +1,260 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { ServerIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { + deregisterManagedRelayEnvironmentCommand, + useManagedRelayEnvironments, +} from "../../cloud/managedRelayState"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { toastManager } from "../ui/toast"; +import { + ClerkUserProfilePage, + ClerkUserProfileRefreshButton, + ClerkUserProfileRow, +} from "./ClerkUserProfilePage"; + +const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); + +function linkedAtLabel(value: string): string { + const linkedAt = new Date(value); + return Number.isNaN(linkedAt.getTime()) + ? "Link date unavailable" + : `Linked ${linkedAtFormatter.format(linkedAt)}`; +} + +function endpointLabel(environment: RelayClientEnvironmentRecord): string { + return environment.endpoint.providerKind === "cloudflare_tunnel" + ? "Managed tunnel" + : "Activity publishing only"; +} + +export function T3ConnectEnvironmentRow(props: { + readonly environment: RelayClientEnvironmentRecord; + readonly confirmationOpen: boolean; + readonly mutationPending: boolean; + readonly onConfirmationChange: (open: boolean) => void; + readonly onDeregister: (environment: RelayClientEnvironmentRecord) => void; +}) { + const { environment } = props; + return ( + }> + +
    +
    +

    + {environment.label} +

    +

    + {linkedAtLabel(environment.linkedAt)} · {endpointLabel(environment)} +

    +
    + + Deregister + + } + /> +
    + + +
    +
    +

    + Deregister server +

    +

    + “{environment.label}” will be removed from this account. +

    +

    + T3 Connect access will be revoked, any managed tunnel will be removed, and a host + space will become available. Local connections on your devices are not changed. +

    +
    + + +
    +
    +
    +
    +
    +
    + ); +} + +export function T3ConnectUserProfilePage() { + const environmentsState = useManagedRelayEnvironments(); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); + const [confirmingEnvironmentId, setConfirmingEnvironmentId] = useState( + null, + ); + const mutationPendingRef = useRef(false); + const [removedEnvironments, setRemovedEnvironments] = useState<{ + readonly accountId: string | null; + readonly linkedAtById: ReadonlyMap; + }>({ accountId: null, linkedAtById: new Map() }); + + const handleDeregister = async (environment: RelayClientEnvironmentRecord) => { + const accountId = environmentsState.accountId; + if (!accountId || mutationPendingRef.current) return; + + mutationPendingRef.current = true; + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ + accountId, + environmentId: environment.environmentId, + }); + mutationPendingRef.current = false; + setDeregisteringEnvironmentId(null); + + if (result._tag === "Success") { + setConfirmingEnvironmentId(null); + setRemovedEnvironments((current) => { + const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []); + linkedAtById.set(environment.environmentId, environment.linkedAt); + return { accountId, linkedAtById }; + }); + environmentsState.refresh(); + toastManager.add({ + type: "success", + title: "Server deregistered", + description: "T3 Connect access was revoked and a 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 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 removedEnvironmentLinkedAt = + removedEnvironments.accountId === environmentsState.accountId + ? removedEnvironments.linkedAtById + : new Map(); + const environments = (environmentsState.data ?? []).filter( + (environment) => + removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, + ); + const isInitialLoad = + !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); + + return ( + + } + > +
    + {environmentsState.error ? ( +
    +

    + Could not load T3 Connect environments +

    +

    {environmentsState.error}

    +
    + ) : null} + + {isInitialLoad ? ( +

    + Loading environments… +

    + ) : environments.length > 0 ? ( +
      + {environments.map((environment) => ( + + setConfirmingEnvironmentId(open ? environment.environmentId : null) + } + onDeregister={(selected) => void handleDeregister(selected)} + /> + ))} +
    + ) : environmentsState.error ? null : ( + + + + + + + No T3 Connect environments + + + Link an environment from its local Settings to make it available through T3 Connect. + + + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 006c3f9e93c6..4f57e920118b 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -46,12 +46,14 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp function AlertDialogPopup({ className, bottomStickOnMobile = true, + portalContainer, ...props }: AlertDialogPrimitive.Popup.Props & { bottomStickOnMobile?: boolean; + portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; }) { return ( - + , - onQueryEvent?: (event: ManagedRelayQueryEvent) => void, -) { - const client = ManagedRelay.ManagedRelayClient.of({ +function createClient(overrides?: Partial) { + return ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.succeed([environment]), listDevices: () => Effect.succeed([device]), @@ -87,6 +86,13 @@ function createManager( resetTokenCache: Effect.void, ...overrides, }); +} + +function createManager( + overrides?: Partial, + onQueryEvent?: (event: ManagedRelayQueryEvent) => void, +) { + const client = createClient(overrides); const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000, @@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => { }), ); + it.effect("deregisters an environment through the current Clerk session", () => + Effect.gen(function* () { + const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); + setSession(); + + yield* deregisterManagedRelayEnvironment(registry, { + accountId: "account-1", + environmentId: environment.environmentId, + }).pipe( + Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })), + ); + + expect(unlinkEnvironment).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + environmentId: environment.environmentId, + }); + }), + ); + + it.effect("rejects deregistration after the account changes", () => + Effect.gen(function* () { + const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); + setSession(); + + const error = yield* deregisterManagedRelayEnvironment(registry, { + accountId: "previous-account", + environmentId: environment.environmentId, + }).pipe( + Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })), + Effect.flip, + ); + + expect(error).toBeInstanceOf(ManagedRelaySessionError); + expect(unlinkEnvironment).not.toHaveBeenCalled(); + }), + ); + it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => Effect.gen(function* () { const token = clerkToken(4_102_444_800); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 1d12c90aae5a..1a3a22efb204 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -2,6 +2,7 @@ import type { RelayClientEnvironmentRecord, RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, @@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn( }); }); +/** Removes an environment from the signed-in account without contacting that environment. */ +export const deregisterManagedRelayEnvironment = Effect.fn( + "clientRuntime.managedRelaySession.deregisterEnvironment", +)(function* ( + registry: AtomRegistry.AtomRegistry, + input: { readonly accountId: string; readonly environmentId: EnvironmentId }, +) { + const session = registry.get(managedRelaySessionAtom); + if (!session || session.accountId !== input.accountId) { + return yield* new ManagedRelaySessionError({ + message: "Sign in to T3 Connect before deregistering an environment.", + }); + } + const clerkToken = yield* readSessionClerkToken(session); + const relay = yield* ManagedRelay.ManagedRelayClient; + yield* relay.unlinkEnvironment({ clerkToken, environmentId: input.environmentId }); +}); + function requireClerkToken( get: Atom.AtomContext, accountId: string,