authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-12 22:46:23+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 22:46:23+02:00
logd0b8d6306b4686528913948b21c8a9eeda01645a
tree061b082c3a27e0ab4353dbbc9741b276c2cfab71
parent9666b875164581b67e5d75cf26aa05753e83e49e
signature Signed by PGP key B5690EEEBB952194

feat(connect): deregister account environments from any client (#4844)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

10 files changed, 573 insertions(+), 70 deletions(-)

apps/web/src/cloud/managedRelayState.ts+22
...@@ -1,14 +1,20 @@...@@ -1,14 +1,20 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 createManagedRelayQueryManager,3 createManagedRelayQueryManager,
4 deregisterManagedRelayEnvironment,
4 ManagedRelay,5 ManagedRelay,
5 managedRelaySessionAtom,6 managedRelaySessionAtom,
6 readManagedRelaySnapshotState,7 readManagedRelaySnapshotState,
7} from "@t3tools/client-runtime/relay";8} from "@t3tools/client-runtime/relay";
9import {
10 createAtomCommandScheduler,
11 createRuntimeCommand,
12} from "@t3tools/client-runtime/state/runtime";
8import type {13import type {
9 RelayClientDeviceRecord,14 RelayClientDeviceRecord,
10 RelayClientEnvironmentRecord,15 RelayClientEnvironmentRecord,
11} from "@t3tools/contracts/relay";16} from "@t3tools/contracts/relay";
17import type { EnvironmentId } from "@t3tools/contracts";
12import * as Context from "effect/Context";18import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";20import * as Layer from "effect/Layer";
...@@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime(...@@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime(
2935
30export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);36export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);
3137
38const managedRelayMutationScheduler = createAtomCommandScheduler();
39
40export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand(
41 managedRelayAtomRuntime,
42 {
43 label: "web:managed-relay:deregister-environment",
44 scheduler: managedRelayMutationScheduler,
45 concurrency: {
46 mode: "serial",
47 key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) =>
48 input.accountId,
49 },
50 execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input),
51 },
52);
53
32const EMPTY_ENVIRONMENTS_ATOM = Atom.make(54const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
33 AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),55 AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
34).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null"));56).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null"));
apps/web/src/components/clerk/ClerkUserProfilePage.tsx created+86
...@@ -0,0 +1,86 @@
1import { RefreshCwIcon } from "lucide-react";
2import type { ReactNode } from "react";
3
4import { cn } from "../../lib/utils";
5import { Button } from "../ui/button";
6
7export function ClerkUserProfilePage({
8 action,
9 children,
10 className,
11 description,
12 title,
13}: {
14 readonly action?: ReactNode;
15 readonly children: ReactNode;
16 readonly className?: string;
17 readonly description?: ReactNode;
18 readonly title: ReactNode;
19}) {
20 return (
21 <div className={cn("w-full min-w-0 text-foreground", className)}>
22 <header className="flex flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
23 <div className="min-w-0">
24 <h2 className="text-[1.0625rem] leading-6 font-semibold">{title}</h2>
25 {description ? (
26 <p className="mt-1 max-w-[34rem] text-[0.8125rem] leading-[1.125rem] text-muted-foreground sm:min-h-9">
27 {description}
28 </p>
29 ) : null}
30 </div>
31 {action ? <div className="shrink-0">{action}</div> : null}
32 </header>
33
34 {children}
35 </div>
36 );
37}
38
39export function ClerkUserProfileRefreshButton({
40 className,
41 disabled = false,
42 isPending,
43 onClick,
44}: {
45 readonly className?: string;
46 readonly disabled?: boolean;
47 readonly isPending: boolean;
48 readonly onClick: () => void;
49}) {
50 return (
51 <Button
52 size="sm"
53 variant="outline"
54 className={cn("text-[0.8125rem]", className)}
55 disabled={disabled || isPending}
56 onClick={onClick}
57 >
58 <RefreshCwIcon aria-hidden="true" className={cn("size-3.5", isPending && "animate-spin")} />
59 Refresh
60 </Button>
61 );
62}
63
64export function ClerkUserProfileRow({
65 children,
66 className,
67 icon,
68}: {
69 readonly children: ReactNode;
70 readonly className?: string;
71 readonly icon: ReactNode;
72}) {
73 return (
74 <li className={cn("border-t py-4 first:border-t-0", className)}>
75 <div className="flex items-start gap-3">
76 <div
77 aria-hidden="true"
78 className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-muted/32 text-muted-foreground"
79 >
80 {icon}
81 </div>
82 <div className="min-w-0 flex-1">{children}</div>
83 </div>
84 </li>
85 );
86}
apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx+52-63
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";1import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
2import { RefreshCwIcon, SmartphoneIcon } from "lucide-react";2import { SmartphoneIcon } from "lucide-react";
33
4import { useManagedRelayDevices } from "../../cloud/managedRelayState";4import { useManagedRelayDevices } from "../../cloud/managedRelayState";
5import { cn } from "../../lib/utils";
6import { Badge } from "../ui/badge";5import { Badge } from "../ui/badge";
7import { Button } from "../ui/button";6import { Button } from "../ui/button";
8import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";7import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
...@@ -12,6 +11,11 @@ import {...@@ -12,6 +11,11 @@ import {
12 mobileClientPlatformLabel,11 mobileClientPlatformLabel,
13 mobileClientUpdatedAtLabel,12 mobileClientUpdatedAtLabel,
14} from "./MobileClientsUserProfilePage.logic";13} from "./MobileClientsUserProfilePage.logic";
14import {
15 ClerkUserProfilePage,
16 ClerkUserProfileRefreshButton,
17 ClerkUserProfileRow,
18} from "./ClerkUserProfilePage";
1519
16const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const;20const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const;
1721
...@@ -31,53 +35,47 @@ function MobileClientStatusBadge({...@@ -31,53 +35,47 @@ function MobileClientStatusBadge({
3135
32function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) {36function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) {
33 return (37 return (
34 <li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4">38 <ClerkUserProfileRow icon={<SmartphoneIcon className="size-4" />}>
35 <div className="flex items-start gap-3">39 <div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
36 <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground">40 <div className="min-w-0">
37 <SmartphoneIcon className="size-4" />41 <h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground">
38 </div>42 {device.label}
39 <div className="min-w-0 flex-1">43 </h3>
40 <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">44 <p className="text-xs leading-[1.125rem] text-muted-foreground">
41 <div className="min-w-0">45 {mobileClientPlatformLabel(device)}
42 <h3 className="truncate text-sm font-semibold text-foreground">{device.label}</h3>
43 <p className="text-xs text-muted-foreground">{mobileClientPlatformLabel(device)}</p>
44 </div>
45 <p className="shrink-0 text-[11px] text-muted-foreground/75">
46 {mobileClientUpdatedAtLabel(device.updatedAt)}
47 </p>
48 </div>
49 <div className="mt-3 flex flex-wrap gap-1.5">
50 <MobileClientStatusBadge
51 enabled={device.notifications.enabled}
52 label="Push notifications"
53 />
54 <MobileClientStatusBadge
55 enabled={device.liveActivities.enabled}
56 label="Live Activities"
57 />
58 </div>
59 <p className="mt-2 text-xs leading-relaxed text-muted-foreground/80">
60 {mobileClientNotificationDetail(device)}
61 </p>46 </p>
62 </div>47 </div>
48 <p className="shrink-0 text-[0.6875rem] leading-4 text-muted-foreground/75">
49 {mobileClientUpdatedAtLabel(device.updatedAt)}
50 </p>
63 </div>51 </div>
64 </li>52 <div className="mt-2 flex flex-wrap gap-1.5">
53 <MobileClientStatusBadge
54 enabled={device.notifications.enabled}
55 label="Push notifications"
56 />
57 <MobileClientStatusBadge enabled={device.liveActivities.enabled} label="Live Activities" />
58 </div>
59 <p className="mt-1.5 text-xs leading-[1.125rem] text-muted-foreground/80">
60 {mobileClientNotificationDetail(device)}
61 </p>
62 </ClerkUserProfileRow>
65 );63 );
66}64}
6765
68function MobileClientsSkeleton() {66function MobileClientsSkeleton() {
69 return (67 return (
70 <div aria-label="Loading mobile clients" className="space-y-3" role="status">68 <div aria-label="Loading mobile clients" className="divide-y border-t" role="status">
71 {MOBILE_CLIENT_SKELETON_ROWS.map((row) => (69 {MOBILE_CLIENT_SKELETON_ROWS.map((row) => (
72 <div key={row} className="rounded-xl border p-4">70 <div key={row} className="py-4">
73 <div className="flex gap-3">71 <div className="flex gap-3">
74 <Skeleton className="size-9 shrink-0 rounded-lg" />72 <Skeleton className="size-8 shrink-0 rounded-lg" />
75 <div className="flex-1 space-y-2">73 <div className="flex-1 space-y-2">
76 <Skeleton className="h-4 w-36" />74 <Skeleton className="h-3.5 w-36" />
77 <Skeleton className="h-3 w-28" />75 <Skeleton className="h-3 w-28" />
78 <div className="flex gap-2 pt-1">76 <div className="flex gap-2">
79 <Skeleton className="h-5 w-28" />77 <Skeleton className="h-4.5 w-28" />
80 <Skeleton className="h-5 w-24" />78 <Skeleton className="h-4.5 w-24" />
81 </div>79 </div>
82 </div>80 </div>
83 </div>81 </div>
...@@ -89,13 +87,13 @@ function MobileClientsSkeleton() {...@@ -89,13 +87,13 @@ function MobileClientsSkeleton() {
8987
90function EmptyMobileClients() {88function EmptyMobileClients() {
91 return (89 return (
92 <Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15">90 <Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10">
93 <EmptyMedia variant="icon">91 <EmptyMedia className="mb-0" variant="icon">
94 <SmartphoneIcon />92 <SmartphoneIcon />
95 </EmptyMedia>93 </EmptyMedia>
96 <EmptyHeader>94 <EmptyHeader>
97 <EmptyTitle>No mobile clients</EmptyTitle>95 <EmptyTitle className="text-[1.0625rem] leading-6">No mobile clients</EmptyTitle>
98 <EmptyDescription>96 <EmptyDescription className="text-[0.8125rem] leading-[1.125rem]">
99 Sign in to T3 Code on your iPhone to register it for push notifications and Live97 Sign in to T3 Code on your iPhone to register it for push notifications and Live
100 Activities.98 Activities.
101 </EmptyDescription>99 </EmptyDescription>
...@@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() {...@@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() {
112 const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null;110 const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null;
113111
114 return (112 return (
115 <div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground">113 <ClerkUserProfilePage
116 <header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">114 title="Mobile clients"
117 <div>115 description="Devices registered to receive T3 Connect activity from your environments."
118 <h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2>116 action={
119 <p className="mt-1 text-sm text-muted-foreground">117 <ClerkUserProfileRefreshButton
120 Devices registered to receive T3 Connect activity from your environments.118 isPending={devicesState.isPending}
121 </p>
122 </div>
123 <Button
124 size="sm"
125 variant="outline"
126 disabled={devicesState.isPending}
127 onClick={devicesState.refresh}119 onClick={devicesState.refresh}
128 >120 />
129 <RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} />121 }
130 Refresh122 >
131 </Button>123 <div>
132 </header>
133
134 <div className="flex-1 p-6">
135 {devicesState.error ? (124 {devicesState.error ? (
136 <div125 <div
137 className="mb-4 flex flex-col gap-3 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center sm:justify-between"126 className="mb-4 flex flex-col gap-3 border-t border-destructive/35 py-3 text-[0.8125rem] sm:flex-row sm:items-center sm:justify-between"
138 role="alert"127 role="alert"
139 >128 >
140 <div>129 <div>
...@@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() {...@@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() {
152 {isInitialLoad ? (141 {isInitialLoad ? (
153 <MobileClientsSkeleton />142 <MobileClientsSkeleton />
154 ) : hasErrorWithoutData ? null : devices.length > 0 ? (143 ) : hasErrorWithoutData ? null : devices.length > 0 ? (
155 <ul className="space-y-3">144 <ul className="border-t">
156 {devices.map((device) => (145 {devices.map((device) => (
157 <MobileClientRow key={device.deviceId} device={device} />146 <MobileClientRow key={device.deviceId} device={device} />
158 ))}147 ))}
...@@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() {...@@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() {
161 <EmptyMobileClients />150 <EmptyMobileClients />
162 )}151 )}
163 </div>152 </div>
164 </div>153 </ClerkUserProfilePage>
165 );154 );
166}155}
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+9-1
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { UserButton, useAuth } from "@clerk/react";1import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon, SmartphoneIcon } from "lucide-react";2import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react";
33
4import { hasCloudPublicConfig } from "../../cloud/publicConfig";4import { hasCloudPublicConfig } from "../../cloud/publicConfig";
5import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";5import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
6import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";6import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
7import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
7import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";8import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
89
9export function T3ConnectSidebarSignIn() {10export function T3ConnectSidebarSignIn() {
...@@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() {...@@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() {
39 >40 >
40 <MobileClientsUserProfilePage />41 <MobileClientsUserProfilePage />
41 </UserButton.UserProfilePage>42 </UserButton.UserProfilePage>
43 <UserButton.UserProfilePage
44 label="T3 Connect"
45 labelIcon={<ServerIcon className="size-4" />}
46 url="t3-connect"
47 >
48 <T3ConnectUserProfilePage />
49 </UserButton.UserProfilePage>
42 </UserButton>50 </UserButton>
43 );51 );
44}52}
apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx created+63
...@@ -0,0 +1,63 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
3import { renderToStaticMarkup } from "react-dom/server";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import { T3ConnectEnvironmentRow } from "./T3ConnectUserProfilePage";
7
8const environment: RelayClientEnvironmentRecord = {
9 environmentId: "environment-1" as EnvironmentId,
10 label: "Studio Mac",
11 endpoint: {
12 httpBaseUrl: "https://studio.example.com",
13 wsBaseUrl: "wss://studio.example.com",
14 providerKind: "cloudflare_tunnel",
15 },
16 linkedAt: "2026-08-12T12:00:00.000Z",
17};
18
19function renderRow({
20 confirmationOpen = false,
21 mutationPending = false,
22}: {
23 readonly confirmationOpen?: boolean;
24 readonly mutationPending?: boolean;
25} = {}) {
26 return renderToStaticMarkup(
27 <T3ConnectEnvironmentRow
28 environment={environment}
29 confirmationOpen={confirmationOpen}
30 mutationPending={mutationPending}
31 onConfirmationChange={vi.fn()}
32 onDeregister={vi.fn()}
33 />,
34 );
35}
36
37describe("T3 Connect environment row", () => {
38 it("keeps deregistration confirmation inline and collapsed by default", () => {
39 const markup = renderRow();
40
41 expect(markup).toContain("Studio Mac");
42 expect(markup).toContain("Deregister");
43 expect(markup).not.toContain("Deregister server");
44 expect(markup).not.toContain("Confirm deregistration of Studio Mac");
45 });
46
47 it("expands Clerk-style confirmation content beneath the environment row", () => {
48 const markup = renderRow({ confirmationOpen: true });
49
50 expect(markup).toContain("Deregister server");
51 expect(markup).toContain("“Studio Mac” will be removed from this account.");
52 expect(markup).toContain("Confirm deregistration of Studio Mac");
53 expect(markup).toContain("Local connections on your devices are not changed.");
54 expect(markup).toContain("Cancel");
55 });
56
57 it("locks the confirmation actions while deregistration is pending", () => {
58 const markup = renderRow({ confirmationOpen: true, mutationPending: true });
59
60 expect(markup).toContain("Deregistering…");
61 expect(markup.match(/ disabled=""/g)).toHaveLength(3);
62 });
63});
apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx created+260
...@@ -0,0 +1,260 @@
1import { findErrorTraceId } from "@t3tools/client-runtime/errors";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId } from "@t3tools/contracts";
7import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
8import { ServerIcon } from "lucide-react";
9import { useRef, useState } from "react";
10
11import {
12 deregisterManagedRelayEnvironmentCommand,
13 useManagedRelayEnvironments,
14} from "../../cloud/managedRelayState";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { Button } from "../ui/button";
17import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
18import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
19import { toastManager } from "../ui/toast";
20import {
21 ClerkUserProfilePage,
22 ClerkUserProfileRefreshButton,
23 ClerkUserProfileRow,
24} from "./ClerkUserProfilePage";
25
26const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
27
28function linkedAtLabel(value: string): string {
29 const linkedAt = new Date(value);
30 return Number.isNaN(linkedAt.getTime())
31 ? "Link date unavailable"
32 : `Linked ${linkedAtFormatter.format(linkedAt)}`;
33}
34
35function endpointLabel(environment: RelayClientEnvironmentRecord): string {
36 return environment.endpoint.providerKind === "cloudflare_tunnel"
37 ? "Managed tunnel"
38 : "Activity publishing only";
39}
40
41export function T3ConnectEnvironmentRow(props: {
42 readonly environment: RelayClientEnvironmentRecord;
43 readonly confirmationOpen: boolean;
44 readonly mutationPending: boolean;
45 readonly onConfirmationChange: (open: boolean) => void;
46 readonly onDeregister: (environment: RelayClientEnvironmentRecord) => void;
47}) {
48 const { environment } = props;
49 return (
50 <ClerkUserProfileRow icon={<ServerIcon className="size-4" />}>
51 <Collapsible open={props.confirmationOpen} onOpenChange={props.onConfirmationChange}>
52 <div className="flex items-start gap-4">
53 <div className="min-w-0 flex-1">
54 <h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground">
55 {environment.label}
56 </h3>
57 <p className="mt-1 text-xs leading-[1.125rem] text-muted-foreground">
58 {linkedAtLabel(environment.linkedAt)} · {endpointLabel(environment)}
59 </p>
60 </div>
61 <CollapsibleTrigger
62 render={
63 <Button
64 size="sm"
65 variant="destructive-outline"
66 className="text-[0.8125rem]"
67 disabled={props.mutationPending}
68 >
69 Deregister
70 </Button>
71 }
72 />
73 </div>
74
75 <CollapsiblePanel>
76 <div className="pt-3">
77 <div
78 className="rounded-lg border border-input bg-muted/32 px-5 py-4 shadow-xs/5"
79 role="group"
80 aria-label={`Confirm deregistration of ${environment.label}`}
81 >
82 <h4 className="text-[0.8125rem] leading-[1.125rem] font-semibold text-foreground">
83 Deregister server
84 </h4>
85 <p className="mt-1 text-[0.8125rem] leading-[1.125rem] text-muted-foreground">
86 “{environment.label}” will be removed from this account.
87 </p>
88 <p className="mt-4 max-w-xl text-[0.8125rem] leading-[1.125rem] text-muted-foreground">
89 T3 Connect access will be revoked, any managed tunnel will be removed, and a host
90 space will become available. Local connections on your devices are not changed.
91 </p>
92 <div className="mt-4 flex justify-end gap-2">
93 <Button
94 size="sm"
95 variant="ghost"
96 className="text-[0.8125rem]"
97 disabled={props.mutationPending}
98 onClick={() => props.onConfirmationChange(false)}
99 >
100 Cancel
101 </Button>
102 <Button
103 size="sm"
104 variant="destructive"
105 className="text-[0.8125rem]"
106 disabled={props.mutationPending}
107 onClick={() => props.onDeregister(environment)}
108 >
109 {props.mutationPending ? "Deregistering…" : "Deregister"}
110 </Button>
111 </div>
112 </div>
113 </div>
114 </CollapsiblePanel>
115 </Collapsible>
116 </ClerkUserProfileRow>
117 );
118}
119
120export function T3ConnectUserProfilePage() {
121 const environmentsState = useManagedRelayEnvironments();
122 const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, {
123 reportFailure: false,
124 });
125 const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] =
126 useState<EnvironmentId | null>(null);
127 const [confirmingEnvironmentId, setConfirmingEnvironmentId] = useState<EnvironmentId | null>(
128 null,
129 );
130 const mutationPendingRef = useRef(false);
131 const [removedEnvironments, setRemovedEnvironments] = useState<{
132 readonly accountId: string | null;
133 readonly linkedAtById: ReadonlyMap<EnvironmentId, string>;
134 }>({ accountId: null, linkedAtById: new Map() });
135
136 const handleDeregister = async (environment: RelayClientEnvironmentRecord) => {
137 const accountId = environmentsState.accountId;
138 if (!accountId || mutationPendingRef.current) return;
139
140 mutationPendingRef.current = true;
141 setDeregisteringEnvironmentId(environment.environmentId);
142 const result = await deregisterEnvironment({
143 accountId,
144 environmentId: environment.environmentId,
145 });
146 mutationPendingRef.current = false;
147 setDeregisteringEnvironmentId(null);
148
149 if (result._tag === "Success") {
150 setConfirmingEnvironmentId(null);
151 setRemovedEnvironments((current) => {
152 const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []);
153 linkedAtById.set(environment.environmentId, environment.linkedAt);
154 return { accountId, linkedAtById };
155 });
156 environmentsState.refresh();
157 toastManager.add({
158 type: "success",
159 title: "Server deregistered",
160 description: "T3 Connect access was revoked and a host space is now available.",
161 });
162 return;
163 }
164 if (isAtomCommandInterrupted(result)) return;
165
166 const cause = squashAtomCommandFailure(result);
167 const message = cause instanceof Error ? cause.message : "Could not deregister the server.";
168 const traceId = findErrorTraceId(cause);
169 console.error("[t3-connect] Could not deregister environment", {
170 environmentId: environment.environmentId,
171 message,
172 traceId,
173 cause,
174 });
175 toastManager.add({
176 type: "error",
177 title: "Could not deregister server",
178 description: message,
179 data: traceId
180 ? {
181 secondaryActionProps: {
182 children: "Copy trace ID",
183 onClick: () => void navigator.clipboard?.writeText(traceId),
184 },
185 }
186 : undefined,
187 });
188 };
189
190 const removedEnvironmentLinkedAt =
191 removedEnvironments.accountId === environmentsState.accountId
192 ? removedEnvironments.linkedAtById
193 : new Map<EnvironmentId, string>();
194 const environments = (environmentsState.data ?? []).filter(
195 (environment) =>
196 removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt,
197 );
198 const isInitialLoad =
199 !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error);
200
201 return (
202 <ClerkUserProfilePage
203 title="T3 Connect"
204 description="Environments registered to your account. Connections on this device are managed in Settings."
205 action={
206 <ClerkUserProfileRefreshButton
207 disabled={deregisteringEnvironmentId !== null}
208 isPending={environmentsState.isPending}
209 onClick={environmentsState.refresh}
210 />
211 }
212 >
213 <div>
214 {environmentsState.error ? (
215 <div className="mb-4 border-t border-destructive/35 py-3 text-[0.8125rem]" role="alert">
216 <p className="font-medium text-destructive-foreground">
217 Could not load T3 Connect environments
218 </p>
219 <p className="mt-1 text-xs text-muted-foreground">{environmentsState.error}</p>
220 </div>
221 ) : null}
222
223 {isInitialLoad ? (
224 <p className="border-t py-4 text-[0.8125rem] text-muted-foreground" role="status">
225 Loading environments…
226 </p>
227 ) : environments.length > 0 ? (
228 <ul className="border-t">
229 {environments.map((environment) => (
230 <T3ConnectEnvironmentRow
231 key={environment.environmentId}
232 environment={environment}
233 confirmationOpen={confirmingEnvironmentId === environment.environmentId}
234 mutationPending={deregisteringEnvironmentId !== null}
235 onConfirmationChange={(open) =>
236 setConfirmingEnvironmentId(open ? environment.environmentId : null)
237 }
238 onDeregister={(selected) => void handleDeregister(selected)}
239 />
240 ))}
241 </ul>
242 ) : environmentsState.error ? null : (
243 <Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10">
244 <EmptyMedia className="mb-0" variant="icon">
245 <ServerIcon />
246 </EmptyMedia>
247 <EmptyHeader>
248 <EmptyTitle className="text-[1.0625rem] leading-6">
249 No T3 Connect environments
250 </EmptyTitle>
251 <EmptyDescription className="text-[0.8125rem] leading-[1.125rem]">
252 Link an environment from its local Settings to make it available through T3 Connect.
253 </EmptyDescription>
254 </EmptyHeader>
255 </Empty>
256 )}
257 </div>
258 </ClerkUserProfilePage>
259 );
260}
apps/web/src/components/ui/alert-dialog.tsx+3-1
...@@ -46,12 +46,14 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp...@@ -46,12 +46,14 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp
46function AlertDialogPopup({46function AlertDialogPopup({
47 className,47 className,
48 bottomStickOnMobile = true,48 bottomStickOnMobile = true,
49 portalContainer,
49 ...props50 ...props
50}: AlertDialogPrimitive.Popup.Props & {51}: AlertDialogPrimitive.Popup.Props & {
51 bottomStickOnMobile?: boolean;52 bottomStickOnMobile?: boolean;
53 portalContainer?: AlertDialogPrimitive.Portal.Props["container"];
52}) {54}) {
53 return (55 return (
54 <AlertDialogPortal>56 <AlertDialogPortal container={portalContainer}>
55 <AlertDialogBackdrop />57 <AlertDialogBackdrop />
56 <AlertDialogViewport58 <AlertDialogViewport
57 className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}59 className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}
docs/user/remote-access.md+11
...@@ -218,6 +218,17 @@ Typical uses:...@@ -218,6 +218,17 @@ Typical uses:
218218
219Use `t3 auth --help` and the nested subcommand help pages for the full reference.219Use `t3 auth --help` and the nested subcommand help pages for the full reference.
220220
221### Deregister a T3 Connect Environment
222
223Open your account menu and choose **T3 Connect** to see every environment registered to your
224account. On mobile, open **Settings** → **T3 Connect**. Choose **Deregister** to revoke an
225environment's T3 Connect access, remove any managed tunnel, and free its host space.
226
227Deregistration is an account action and does not need a connection to the environment, so it also
228works for a server that was wiped or is no longer reachable. Device-local connect and disconnect
229controls remain in **Settings** → **Connections** on web and desktop or **Settings** →
230**Environments** on mobile.
231
221## Security Notes232## Security Notes
222233
223- Treat pairing URLs and pairing tokens like passwords.234- Treat pairing URLs and pairing tokens like passwords.
packages/client-runtime/src/relay/managedRelayState.test.ts+48-5
...@@ -16,8 +16,10 @@ import * as ManagedRelay from "./managedRelay.ts";...@@ -16,8 +16,10 @@ import * as ManagedRelay from "./managedRelay.ts";
16import {16import {
17 createManagedRelayQueryManager,17 createManagedRelayQueryManager,
18 createManagedRelaySession,18 createManagedRelaySession,
19 deregisterManagedRelayEnvironment,
19 managedRelayAccountChanges,20 managedRelayAccountChanges,
20 type ManagedRelayQueryEvent,21 type ManagedRelayQueryEvent,
22 ManagedRelaySessionError,
21 managedRelaySessionAtom,23 managedRelaySessionAtom,
22 readManagedRelaySnapshotState,24 readManagedRelaySnapshotState,
23 setManagedRelaySession,25 setManagedRelaySession,
...@@ -61,11 +63,8 @@ function resetRegistry() {...@@ -61,11 +63,8 @@ function resetRegistry() {
61 registry = AtomRegistry.make();63 registry = AtomRegistry.make();
62}64}
6365
64function createManager(66function createClient(overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>) {
65 overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>,67 return ManagedRelay.ManagedRelayClient.of({
66 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,
67) {
68 const client = ManagedRelay.ManagedRelayClient.of({
69 relayUrl: "https://relay.example.test",68 relayUrl: "https://relay.example.test",
70 listEnvironments: () => Effect.succeed([environment]),69 listEnvironments: () => Effect.succeed([environment]),
71 listDevices: () => Effect.succeed([device]),70 listDevices: () => Effect.succeed([device]),
...@@ -87,6 +86,13 @@ function createManager(...@@ -87,6 +86,13 @@ function createManager(
87 resetTokenCache: Effect.void,86 resetTokenCache: Effect.void,
88 ...overrides,87 ...overrides,
89 });88 });
89}
90
91function createManager(
92 overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>,
93 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,
94) {
95 const client = createClient(overrides);
90 const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client));96 const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client));
91 return createManagedRelayQueryManager(runtime, {97 return createManagedRelayQueryManager(runtime, {
92 staleTimeMs: 60_000,98 staleTimeMs: 60_000,
...@@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => {...@@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => {
121 }),127 }),
122 );128 );
123129
130 it.effect("deregisters an environment through the current Clerk session", () =>
131 Effect.gen(function* () {
132 const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true }));
133 setSession();
134
135 yield* deregisterManagedRelayEnvironment(registry, {
136 accountId: "account-1",
137 environmentId: environment.environmentId,
138 }).pipe(
139 Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })),
140 );
141
142 expect(unlinkEnvironment).toHaveBeenCalledWith({
143 clerkToken: "clerk-token",
144 environmentId: environment.environmentId,
145 });
146 }),
147 );
148
149 it.effect("rejects deregistration after the account changes", () =>
150 Effect.gen(function* () {
151 const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true }));
152 setSession();
153
154 const error = yield* deregisterManagedRelayEnvironment(registry, {
155 accountId: "previous-account",
156 environmentId: environment.environmentId,
157 }).pipe(
158 Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })),
159 Effect.flip,
160 );
161
162 expect(error).toBeInstanceOf(ManagedRelaySessionError);
163 expect(unlinkEnvironment).not.toHaveBeenCalled();
164 }),
165 );
166
124 it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () =>167 it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () =>
125 Effect.gen(function* () {168 Effect.gen(function* () {
126 const token = clerkToken(4_102_444_800);169 const token = clerkToken(4_102_444_800);
packages/client-runtime/src/relay/managedRelayState.ts+19
...@@ -2,6 +2,7 @@ import type {...@@ -2,6 +2,7 @@ import type {
2 RelayClientEnvironmentRecord,2 RelayClientEnvironmentRecord,
3 RelayEnvironmentStatusResponse,3 RelayEnvironmentStatusResponse,
4} from "@t3tools/contracts/relay";4} from "@t3tools/contracts/relay";
5import type { EnvironmentId } from "@t3tools/contracts";
5import {6import {
6 RelayEnvironmentConnectScope,7 RelayEnvironmentConnectScope,
7 RelayEnvironmentStatusScope,8 RelayEnvironmentStatusScope,
...@@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn(...@@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn(
218 });219 });
219});220});
220221
222/** Removes an environment from the signed-in account without contacting that environment. */
223export const deregisterManagedRelayEnvironment = Effect.fn(
224 "clientRuntime.managedRelaySession.deregisterEnvironment",
225)(function* (
226 registry: AtomRegistry.AtomRegistry,
227 input: { readonly accountId: string; readonly environmentId: EnvironmentId },
228) {
229 const session = registry.get(managedRelaySessionAtom);
230 if (!session || session.accountId !== input.accountId) {
231 return yield* new ManagedRelaySessionError({
232 message: "Sign in to T3 Connect before deregistering an environment.",
233 });
234 }
235 const clerkToken = yield* readSessionClerkToken(session);
236 const relay = yield* ManagedRelay.ManagedRelayClient;
237 yield* relay.unlinkEnvironment({ clerkToken, environmentId: input.environmentId });
238});
239
221function requireClerkToken(240function requireClerkToken(
222 get: Atom.AtomContext,241 get: Atom.AtomContext,
223 accountId: string,242 accountId: string,