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 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
33 createManagedRelayQueryManager,
4 deregisterManagedRelayEnvironment,
45 ManagedRelay,
56 managedRelaySessionAtom,
67 readManagedRelaySnapshotState,
78} from "@t3tools/client-runtime/relay";
9import {
10 createAtomCommandScheduler,
11 createRuntimeCommand,
12} from "@t3tools/client-runtime/state/runtime";
813import type {
914 RelayClientDeviceRecord,
1015 RelayClientEnvironmentRecord,
1116} from "@t3tools/contracts/relay";
17import type { EnvironmentId } from "@t3tools/contracts";
1218import * as Context from "effect/Context";
1319import * as Effect from "effect/Effect";
1420import * as Layer from "effect/Layer";
......@@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime(
2935
3036export 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
3254const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
3355 AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
3456).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 @@
11import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
2import { RefreshCwIcon, SmartphoneIcon } from "lucide-react";
2import { SmartphoneIcon } from "lucide-react";
33
44import { useManagedRelayDevices } from "../../cloud/managedRelayState";
5import { cn } from "../../lib/utils";
65import { Badge } from "../ui/badge";
76import { Button } from "../ui/button";
87import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
......@@ -12,6 +11,11 @@ import {
1211 mobileClientPlatformLabel,
1312 mobileClientUpdatedAtLabel,
1413} from "./MobileClientsUserProfilePage.logic";
14import {
15 ClerkUserProfilePage,
16 ClerkUserProfileRefreshButton,
17 ClerkUserProfileRow,
18} from "./ClerkUserProfilePage";
1519
1620const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const;
1721
......@@ -31,53 +35,47 @@ function MobileClientStatusBadge({
3135
3236function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) {
3337 return (
34 <li className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm/4">
35 <div className="flex items-start gap-3">
36 <div className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/40 text-muted-foreground">
37 <SmartphoneIcon className="size-4" />
38 </div>
39 <div className="min-w-0 flex-1">
40 <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
41 <div className="min-w-0">
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)}
38 <ClerkUserProfileRow icon={<SmartphoneIcon className="size-4" />}>
39 <div className="flex flex-col gap-0.5 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
40 <div className="min-w-0">
41 <h3 className="truncate text-[0.8125rem] leading-[1.125rem] font-medium text-foreground">
42 {device.label}
43 </h3>
44 <p className="text-xs leading-[1.125rem] text-muted-foreground">
45 {mobileClientPlatformLabel(device)}
6146 </p>
6247 </div>
48 <p className="shrink-0 text-[0.6875rem] leading-4 text-muted-foreground/75">
49 {mobileClientUpdatedAtLabel(device.updatedAt)}
50 </p>
6351 </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>
6563 );
6664}
6765
6866function MobileClientsSkeleton() {
6967 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">
7169 {MOBILE_CLIENT_SKELETON_ROWS.map((row) => (
72 <div key={row} className="rounded-xl border p-4">
70 <div key={row} className="py-4">
7371 <div className="flex gap-3">
74 <Skeleton className="size-9 shrink-0 rounded-lg" />
72 <Skeleton className="size-8 shrink-0 rounded-lg" />
7573 <div className="flex-1 space-y-2">
76 <Skeleton className="h-4 w-36" />
74 <Skeleton className="h-3.5 w-36" />
7775 <Skeleton className="h-3 w-28" />
78 <div className="flex gap-2 pt-1">
79 <Skeleton className="h-5 w-28" />
80 <Skeleton className="h-5 w-24" />
76 <div className="flex gap-2">
77 <Skeleton className="h-4.5 w-28" />
78 <Skeleton className="h-4.5 w-24" />
8179 </div>
8280 </div>
8381 </div>
......@@ -89,13 +87,13 @@ function MobileClientsSkeleton() {
8987
9088function EmptyMobileClients() {
9189 return (
92 <Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15">
93 <EmptyMedia variant="icon">
90 <Empty className="min-h-64 gap-4 border-t px-6 py-10 md:p-10">
91 <EmptyMedia className="mb-0" variant="icon">
9492 <SmartphoneIcon />
9593 </EmptyMedia>
9694 <EmptyHeader>
97 <EmptyTitle>No mobile clients</EmptyTitle>
98 <EmptyDescription>
95 <EmptyTitle className="text-[1.0625rem] leading-6">No mobile clients</EmptyTitle>
96 <EmptyDescription className="text-[0.8125rem] leading-[1.125rem]">
9997 Sign in to T3 Code on your iPhone to register it for push notifications and Live
10098 Activities.
10199 </EmptyDescription>
......@@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() {
112110 const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null;
113111
114112 return (
115 <div className="flex min-h-[30rem] w-full flex-col bg-background text-foreground">
116 <header className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
117 <div>
118 <h2 className="text-base font-semibold tracking-[-0.01em]">Mobile clients</h2>
119 <p className="mt-1 text-sm text-muted-foreground">
120 Devices registered to receive T3 Connect activity from your environments.
121 </p>
122 </div>
123 <Button
124 size="sm"
125 variant="outline"
126 disabled={devicesState.isPending}
113 <ClerkUserProfilePage
114 title="Mobile clients"
115 description="Devices registered to receive T3 Connect activity from your environments."
116 action={
117 <ClerkUserProfileRefreshButton
118 isPending={devicesState.isPending}
127119 onClick={devicesState.refresh}
128 >
129 <RefreshCwIcon className={cn("size-3.5", devicesState.isPending && "animate-spin")} />
130 Refresh
131 </Button>
132 </header>
133
134 <div className="flex-1 p-6">
120 />
121 }
122 >
123 <div>
135124 {devicesState.error ? (
136125 <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"
138127 role="alert"
139128 >
140129 <div>
......@@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() {
152141 {isInitialLoad ? (
153142 <MobileClientsSkeleton />
154143 ) : hasErrorWithoutData ? null : devices.length > 0 ? (
155 <ul className="space-y-3">
144 <ul className="border-t">
156145 {devices.map((device) => (
157146 <MobileClientRow key={device.deviceId} device={device} />
158147 ))}
......@@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() {
161150 <EmptyMobileClients />
162151 )}
163152 </div>
164 </div>
153 </ClerkUserProfilePage>
165154 );
166155}
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+9-1
......@@ -1,9 +1,10 @@
11import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon, SmartphoneIcon } from "lucide-react";
2import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react";
33
44import { hasCloudPublicConfig } from "../../cloud/publicConfig";
55import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
66import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
7import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
78import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
89
910export function T3ConnectSidebarSignIn() {
......@@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() {
3940 >
4041 <MobileClientsUserProfilePage />
4142 </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>
4250 </UserButton>
4351 );
4452}
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
4646function AlertDialogPopup({
4747 className,
4848 bottomStickOnMobile = true,
49 portalContainer,
4950 ...props
5051}: AlertDialogPrimitive.Popup.Props & {
5152 bottomStickOnMobile?: boolean;
53 portalContainer?: AlertDialogPrimitive.Portal.Props["container"];
5254}) {
5355 return (
54 <AlertDialogPortal>
56 <AlertDialogPortal container={portalContainer}>
5557 <AlertDialogBackdrop />
5658 <AlertDialogViewport
5759 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:
218218
219219Use `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
221232## Security Notes
222233
223234- 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";
1616import {
1717 createManagedRelayQueryManager,
1818 createManagedRelaySession,
19 deregisterManagedRelayEnvironment,
1920 managedRelayAccountChanges,
2021 type ManagedRelayQueryEvent,
22 ManagedRelaySessionError,
2123 managedRelaySessionAtom,
2224 readManagedRelaySnapshotState,
2325 setManagedRelaySession,
......@@ -61,11 +63,8 @@ function resetRegistry() {
6163 registry = AtomRegistry.make();
6264}
6365
64function createManager(
65 overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>,
66 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,
67) {
68 const client = ManagedRelay.ManagedRelayClient.of({
66function createClient(overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>) {
67 return ManagedRelay.ManagedRelayClient.of({
6968 relayUrl: "https://relay.example.test",
7069 listEnvironments: () => Effect.succeed([environment]),
7170 listDevices: () => Effect.succeed([device]),
......@@ -87,6 +86,13 @@ function createManager(
8786 resetTokenCache: Effect.void,
8887 ...overrides,
8988 });
89}
90
91function createManager(
92 overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>,
93 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,
94) {
95 const client = createClient(overrides);
9096 const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client));
9197 return createManagedRelayQueryManager(runtime, {
9298 staleTimeMs: 60_000,
......@@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => {
121127 }),
122128 );
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
124167 it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () =>
125168 Effect.gen(function* () {
126169 const token = clerkToken(4_102_444_800);
packages/client-runtime/src/relay/managedRelayState.ts+19
......@@ -2,6 +2,7 @@ import type {
22 RelayClientEnvironmentRecord,
33 RelayEnvironmentStatusResponse,
44} from "@t3tools/contracts/relay";
5import type { EnvironmentId } from "@t3tools/contracts";
56import {
67 RelayEnvironmentConnectScope,
78 RelayEnvironmentStatusScope,
......@@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn(
218219 });
219220});
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
221240function requireClerkToken(
222241 get: Atom.AtomContext,
223242 accountId: string,