authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-22 15:58:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-22 15:58:54-07:00
log8cc9a6fa6b0e3b6d6528793e942982d7de040833
treec9056d9ef8951389117c3f47f67edb8eed0342b1
parentb2d17b7108766a989374d670ff12de39b6c4d033
signature Signed by PGP key B5690EEEBB952194

[codex] Restore T3 Connect account controls (#3492)

Co-authored-by: Julius Marminge <julius@mac.lan>

7 files changed, 417 insertions(+), 35 deletions(-)

apps/web/src/cloud/linkEnvironment.test.ts+33
......@@ -33,6 +33,7 @@ import {
3333 readPrimaryCloudLinkState,
3434 type CloudLinkTarget,
3535 unlinkPrimaryEnvironmentFromCloud,
36 updatePrimaryCloudPreferences,
3637} from "./linkEnvironment";
3738
3839const TARGET: CloudLinkTarget = {
......@@ -252,6 +253,38 @@ describe("web cloud link environment client", () => {
252253 }),
253254 );
254255
256 it.effect("updates agent activity publishing for the explicit primary target", () =>
257 Effect.gen(function* () {
258 const fetchMock = vi.fn().mockResolvedValue(
259 Response.json({
260 linked: true,
261 cloudUserId: "user-1",
262 relayUrl: "https://relay.example.test",
263 relayIssuer: "https://relay.example.test",
264 publishAgentActivity: true,
265 }),
266 );
267 vi.stubGlobal("fetch", fetchMock);
268
269 const state = yield* withServices(
270 updatePrimaryCloudPreferences({
271 target: TARGET,
272 publishAgentActivity: true,
273 }),
274 );
275
276 expect(state.publishAgentActivity).toBe(true);
277 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
278 "http://127.0.0.1:3000/api/connect/preferences",
279 );
280 expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
281 // @effect-diagnostics-next-line preferSchemaOverJson:off
282 expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
283 publishAgentActivity: true,
284 });
285 }),
286 );
287
255288 it.effect("links an available primary environment without invoking installation", () =>
256289 Effect.gen(function* () {
257290 const fetchMock = vi
apps/web/src/cloud/linkEnvironmentAtoms.ts+9
......@@ -8,6 +8,7 @@ import {
88 linkPrimaryEnvironmentToCloud,
99 type CloudLinkTarget,
1010 unlinkPrimaryEnvironmentFromCloud,
11 updatePrimaryCloudPreferences,
1112} from "./linkEnvironment";
1213
1314const cloudLinkScheduler = createAtomCommandScheduler();
......@@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti
3132 execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) =>
3233 unlinkPrimaryEnvironmentFromCloud(input),
3334});
35
36export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, {
37 label: "web:cloud:update-primary-environment-preferences",
38 scheduler: cloudLinkScheduler,
39 concurrency: cloudLinkConcurrency,
40 execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) =>
41 updatePrimaryCloudPreferences(input),
42});
apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts created+65
......@@ -0,0 +1,65 @@
1import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 mobileClientNotificationDetail,
6 mobileClientPlatformLabel,
7 mobileClientUpdatedAtLabel,
8} from "./MobileClientsUserProfilePage.logic";
9
10function device(overrides: Partial<RelayClientDeviceRecord> = {}): RelayClientDeviceRecord {
11 return {
12 deviceId: "device-1",
13 label: "Julius’s iPhone",
14 platform: "ios",
15 iosMajorVersion: 18,
16 appVersion: "1.2.3",
17 notifications: {
18 enabled: true,
19 notifyOnApproval: true,
20 notifyOnInput: false,
21 notifyOnCompletion: true,
22 notifyOnFailure: false,
23 },
24 liveActivities: { enabled: true },
25 updatedAt: "2026-06-21T12:00:00.000Z",
26 ...overrides,
27 };
28}
29
30describe("mobile client presentation", () => {
31 it("describes the client platform and enabled notification events", () => {
32 const client = device();
33
34 expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3");
35 expect(mobileClientNotificationDetail(client)).toBe(
36 "Alerts enabled for approvals, completions.",
37 );
38 });
39
40 it("distinguishes disabled notifications from an empty event selection", () => {
41 expect(
42 mobileClientNotificationDetail(
43 device({ notifications: { ...device().notifications, enabled: false } }),
44 ),
45 ).toBe("Push notifications are disabled on this device.");
46 expect(
47 mobileClientNotificationDetail(
48 device({
49 notifications: {
50 enabled: true,
51 notifyOnApproval: false,
52 notifyOnInput: false,
53 notifyOnCompletion: false,
54 notifyOnFailure: false,
55 },
56 }),
57 ),
58 ).toBe("Push notifications are enabled, but no alert types are selected.");
59 });
60
61 it("handles missing app versions and invalid update timestamps", () => {
62 expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18");
63 expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable");
64 });
65});
apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts created+39
......@@ -0,0 +1,39 @@
1import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
2
3const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, {
4 dateStyle: "medium",
5 timeStyle: "short",
6});
7
8const NOTIFICATION_PREFERENCES = [
9 ["notifyOnApproval", "approvals"],
10 ["notifyOnInput", "input requests"],
11 ["notifyOnCompletion", "completions"],
12 ["notifyOnFailure", "failures"],
13] as const satisfies ReadonlyArray<
14 readonly [keyof RelayClientDeviceRecord["notifications"], string]
15>;
16
17export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string {
18 return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`;
19}
20
21export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string {
22 if (!device.notifications.enabled) {
23 return "Push notifications are disabled on this device.";
24 }
25
26 const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) =>
27 device.notifications[preference] ? [label] : [],
28 );
29 return enabledPreferences.length > 0
30 ? `Alerts enabled for ${enabledPreferences.join(", ")}.`
31 : "Push notifications are enabled, but no alert types are selected.";
32}
33
34export function mobileClientUpdatedAtLabel(updatedAt: string): string {
35 const date = new Date(updatedAt);
36 return Number.isNaN(date.getTime())
37 ? "Update time unavailable"
38 : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`;
39}
apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx created+166
......@@ -0,0 +1,166 @@
1import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay";
2import { RefreshCwIcon, SmartphoneIcon } from "lucide-react";
3
4import { useManagedRelayDevices } from "../../cloud/managedRelayState";
5import { cn } from "../../lib/utils";
6import { Badge } from "../ui/badge";
7import { Button } from "../ui/button";
8import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
9import { Skeleton } from "../ui/skeleton";
10import {
11 mobileClientNotificationDetail,
12 mobileClientPlatformLabel,
13 mobileClientUpdatedAtLabel,
14} from "./MobileClientsUserProfilePage.logic";
15
16const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const;
17
18function MobileClientStatusBadge({
19 enabled,
20 label,
21}: {
22 readonly enabled: boolean;
23 readonly label: string;
24}) {
25 return (
26 <Badge variant={enabled ? "success" : "outline"}>
27 {label}: {enabled ? "On" : "Off"}
28 </Badge>
29 );
30}
31
32function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) {
33 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)}
61 </p>
62 </div>
63 </div>
64 </li>
65 );
66}
67
68function MobileClientsSkeleton() {
69 return (
70 <div aria-label="Loading mobile clients" className="space-y-3" role="status">
71 {MOBILE_CLIENT_SKELETON_ROWS.map((row) => (
72 <div key={row} className="rounded-xl border p-4">
73 <div className="flex gap-3">
74 <Skeleton className="size-9 shrink-0 rounded-lg" />
75 <div className="flex-1 space-y-2">
76 <Skeleton className="h-4 w-36" />
77 <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" />
81 </div>
82 </div>
83 </div>
84 </div>
85 ))}
86 </div>
87 );
88}
89
90function EmptyMobileClients() {
91 return (
92 <Empty className="min-h-72 rounded-xl border border-dashed bg-muted/15">
93 <EmptyMedia variant="icon">
94 <SmartphoneIcon />
95 </EmptyMedia>
96 <EmptyHeader>
97 <EmptyTitle>No mobile clients</EmptyTitle>
98 <EmptyDescription>
99 Sign in to T3 Code on your iPhone to register it for push notifications and Live
100 Activities.
101 </EmptyDescription>
102 </EmptyHeader>
103 </Empty>
104 );
105}
106
107export function MobileClientsUserProfilePage() {
108 const devicesState = useManagedRelayDevices();
109 const devices = devicesState.data ?? [];
110 const isInitialLoad =
111 !devicesState.accountId || (devicesState.data === null && !devicesState.error);
112 const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null;
113
114 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}
127 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">
135 {devicesState.error ? (
136 <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"
138 role="alert"
139 >
140 <div>
141 <p className="font-medium text-destructive-foreground">
142 Could not load mobile clients
143 </p>
144 <p className="mt-0.5 text-xs text-muted-foreground">{devicesState.error}</p>
145 </div>
146 <Button size="xs" variant="outline" onClick={devicesState.refresh}>
147 Try again
148 </Button>
149 </div>
150 ) : null}
151
152 {isInitialLoad ? (
153 <MobileClientsSkeleton />
154 ) : hasErrorWithoutData ? null : devices.length > 0 ? (
155 <ul className="space-y-3">
156 {devices.map((device) => (
157 <MobileClientRow key={device.deviceId} device={device} />
158 ))}
159 </ul>
160 ) : (
161 <EmptyMobileClients />
162 )}
163 </div>
164 </div>
165 );
166}
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+11-2
......@@ -1,8 +1,9 @@
11import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon } from "lucide-react";
2import { LogInIcon, SmartphoneIcon } from "lucide-react";
33
44import { hasCloudPublicConfig } from "../../cloud/publicConfig";
55import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
6import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
67import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
78
89export function T3ConnectSidebarSignIn() {
......@@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() {
3031 userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent",
3132 },
3233 }}
33 />
34 >
35 <UserButton.UserProfilePage
36 label="Mobile clients"
37 labelIcon={<SmartphoneIcon className="size-4" />}
38 url="mobile-clients"
39 >
40 <MobileClientsUserProfilePage />
41 </UserButton.UserProfilePage>
42 </UserButton>
3443 );
3544}
3645
apps/web/src/components/settings/ConnectionsSettings.tsx+94-33
......@@ -118,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig";
118118import {
119119 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
120120 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
121 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
121122} from "~/cloud/linkEnvironmentAtoms";
122123import { authEnvironment } from "~/state/auth";
123124import { environmentCatalog } from "~/connection/catalog";
......@@ -1457,7 +1458,7 @@ function SavedBackendListRow({
14571458 : null;
14581459 const metadataBits = [
14591460 sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,
1460 environment.relayManaged ? "T3 Cloud" : null,
1461 environment.relayManaged ? "T3 Connect" : null,
14611462 ].filter((value): value is string => value !== null);
14621463
14631464 return (
......@@ -1575,7 +1576,7 @@ function CloudLinkSwitch({
15751576}) {
15761577 const control = (
15771578 <Switch
1578 aria-label="Enable T3 Cloud"
1579 aria-label="Enable T3 Connect"
15791580 checked={checked}
15801581 disabled={disabled}
15811582 {...(onCheckedChange ? { onCheckedChange } : {})}
......@@ -1602,18 +1603,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16021603 const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, {
16031604 reportFailure: false,
16041605 });
1606 const updatePrimaryEnvironmentPreferences = useAtomCommand(
1607 updatePrimaryEnvironmentPreferencesAtom,
1608 { reportFailure: false },
1609 );
16051610 const primaryCloudLinkState = usePrimaryCloudLinkState();
16061611 const [operationError, setOperationError] = useState<string | null>(null);
16071612 const [isUpdating, setIsUpdating] = useState(false);
1613 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
16081614
16091615 const reportUpdateFailure = (cause: unknown) => {
1610 const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access.";
1616 const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
16111617 const traceId = findErrorTraceId(cause);
1612 console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause });
1618 console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause });
16131619 setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message);
16141620 toastManager.add({
16151621 type: "error",
1616 title: "Could not update T3 Cloud",
1622 title: "Could not update T3 Connect",
16171623 description: message,
16181624 data: traceId
16191625 ? {
......@@ -1643,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16431649 return;
16441650 }
16451651 if (enabled && !tokenResult.value) {
1646 reportUpdateFailure(
1647 new Error("Sign in from T3 Cloud settings before linking this environment."),
1648 );
1652 reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment."));
16491653 setIsUpdating(false);
16501654 return;
16511655 }
......@@ -1680,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16801684
16811685 toastManager.add({
16821686 type: "success",
1683 title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked",
1687 title: enabled ? "T3 Connect linked" : "T3 Connect unlinked",
16841688 description: enabled
1685 ? "This environment is available through T3 Cloud."
1686 : "This environment is no longer available through T3 Cloud.",
1689 ? "This environment is available through T3 Connect."
1690 : "This environment is no longer available through T3 Connect.",
16871691 });
16881692 setIsUpdating(false);
16891693 };
1694
1695 const updatePublishAgentActivity = async (enabled: boolean) => {
1696 const target = primaryCloudLinkState.target;
1697 if (!target) {
1698 reportUpdateFailure(new Error("Local environment is not ready yet."));
1699 return;
1700 }
1701
1702 setIsUpdatingPreference(true);
1703 setOperationError(null);
1704 const updateResult = await updatePrimaryEnvironmentPreferences({
1705 target,
1706 publishAgentActivity: enabled,
1707 });
1708 if (updateResult._tag === "Failure") {
1709 if (!isAtomCommandInterrupted(updateResult)) {
1710 reportUpdateFailure(squashAtomCommandFailure(updateResult));
1711 }
1712 setIsUpdatingPreference(false);
1713 return;
1714 }
1715
1716 primaryCloudLinkState.refresh();
1717 toastManager.add({
1718 type: "success",
1719 title: enabled ? "Agent activity enabled" : "Agent activity disabled",
1720 description: enabled
1721 ? "This environment can publish agent activity to your mobile clients."
1722 : "This environment will stop publishing agent activity.",
1723 });
1724 setIsUpdatingPreference(false);
1725 };
16901726 const disabledReason = !isSignedIn
1691 ? "Sign in from T3 Cloud settings to manage this environment."
1727 ? "Sign in to T3 Connect to manage this environment."
16921728 : !canManageRelay
1693 ? "Your session does not have permission to manage T3 Cloud access."
1729 ? "Your session does not have permission to manage T3 Connect access."
16941730 : null;
16951731 const linked = primaryCloudLinkState.data?.linked ?? false;
16961732
16971733 return (
1698 <SettingsRow
1699 title="T3 Cloud"
1700 description={
1701 linked
1702 ? "This environment is available to your other devices through T3 Cloud."
1703 : "Make this environment available to your other devices through T3 Cloud."
1704 }
1705 status={operationError ?? primaryCloudLinkState.error}
1706 control={
1707 <CloudLinkSwitch
1708 checked={linked}
1709 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating}
1710 disabledReason={disabledReason}
1711 onCheckedChange={(enabled) => void updateLink(enabled)}
1734 <>
1735 <SettingsRow
1736 title="T3 Connect"
1737 description={
1738 linked
1739 ? "This environment is available to your other devices through T3 Connect."
1740 : "Make this environment available to your other devices through T3 Connect."
1741 }
1742 status={operationError ?? primaryCloudLinkState.error}
1743 control={
1744 <CloudLinkSwitch
1745 checked={linked}
1746 disabled={
1747 !canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating
1748 }
1749 disabledReason={disabledReason}
1750 onCheckedChange={(enabled) => void updateLink(enabled)}
1751 />
1752 }
1753 />
1754 {linked ? (
1755 <SettingsRow
1756 title="Publish agent activity"
1757 description="Send activity from this environment to your mobile clients for push notifications and Live Activities."
1758 className="bg-muted/20 pl-7 sm:pl-8"
1759 control={
1760 <Switch
1761 aria-label="Publish agent activity to mobile clients"
1762 checked={primaryCloudLinkState.data?.publishAgentActivity ?? false}
1763 disabled={
1764 !canManageRelay ||
1765 !isSignedIn ||
1766 primaryCloudLinkState.isPending ||
1767 isUpdating ||
1768 isUpdatingPreference
1769 }
1770 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
1771 />
1772 }
17121773 />
1713 }
1714 />
1774 ) : null}
1775 </>
17151776 );
17161777}
17171778
......@@ -1729,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable
17291790 <EmptyTitle>No saved remote environments</EmptyTitle>
17301791 <EmptyDescription>
17311792 {cloudEnabled
1732 ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud."
1793 ? "Click “Add environment” to pair another environment, or connect one from T3 Connect."
17331794 : "Click “Add environment” to pair another environment."}
17341795 </EmptyDescription>
17351796 </EmptyHeader>
......@@ -1794,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({
17941855 toastManager.add({
17951856 type: "success",
17961857 title: "Environment connected",
1797 description: `${environment.label} is available through T3 Cloud.`,
1858 description: `${environment.label} is available through T3 Connect.`,
17981859 });
17991860 return;
18001861 }
......@@ -1803,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({
18031864 }
18041865 const cause = squashAtomCommandFailure(result);
18051866 const message =
1806 cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment.";
1867 cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.";
18071868 const traceId = findErrorTraceId(cause);
1808 console.error("[t3-cloud] Could not connect environment", { message, traceId, cause });
1869 console.error("[t3-connect] Could not connect environment", { message, traceId, cause });
18091870 toastManager.add({
18101871 type: "error",
18111872 title: "Could not connect environment",