Skip to content

Commit a2edd28

Browse files
feat(connect): require approval for new T3 Connect devices (#36419)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9a4ec17 commit a2edd28

68 files changed

Lines changed: 2932 additions & 145 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
7676
import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
7777
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
78+
import { ConnectDeviceApprovalCoordinator } from "./features/connection/ConnectDeviceApprovalCoordinator";
7879
import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage";
7980
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
8081
import {
@@ -281,6 +282,7 @@ function AppContent() {
281282
<>
282283
<SplashScreenCoordinator />
283284
<SubscriptionUsageCoordinator />
285+
<ConnectDeviceApprovalCoordinator />
284286
<GestureHandlerRootView className="flex-1">
285287
<KeyboardProvider statusBarTranslucent>
286288
<SafeAreaProvider>

‎apps/mobile/src/components/ui/Dialog.android.test.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ it("keeps a dialog raised while the activity is replaced", () => {
9090
expect(current.container.textContent).toContain("Shared content imported");
9191
});
9292

93+
it("tells a replaced alert it was dismissed", () => {
94+
mountHost();
95+
const onDismiss = vi.fn();
96+
act(() => Dialog.alert("Older", undefined, [{ text: "OK" }], { onDismiss }));
97+
act(() => Dialog.alert("Newer", undefined, [{ text: "OK" }]));
98+
99+
expect(onDismiss).toHaveBeenCalledOnce();
100+
});
101+
93102
it("ignores a late action from a dialog that a newer one replaced", () => {
94103
const { container } = mountHost();
95104
const olderAction = vi.fn();

‎apps/mobile/src/components/ui/Dialog.android.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ function setPresented(next: PresentedDialog | null) {
4949
}
5050

5151
function submitRequest(dialog: DialogRequest) {
52+
const replaced = presented;
5253
setPresented({ ...dialog, id: ++nextPresentationId });
54+
// A replaced alert closed without an answer, which its owner hears as a dismissal.
55+
if (replaced?.kind === "alert") replaced.request.options?.onDismiss?.();
5356
}
5457

5558
/** Closes the dialog only if it is still the one the action came from. */

‎apps/mobile/src/features/connection/CloudEnvironmentRows.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ function ConnectedCloudEnvironmentRow(props: {
203203
connectionError={enabled || unsupported ? props.environment.connectionError : null}
204204
connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
205205
connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
206+
awaitingApproval={enabled && presentation?.connection.awaitingApproval === true}
206207
errorExpanded={props.errorExpanded}
207208
label={props.environment.environmentLabel}
208209
machine={resolveEnvironmentMachineKind(
@@ -269,6 +270,8 @@ function CloudEnvironmentRowShell(props: {
269270
readonly connectionError: string | null;
270271
readonly connectionErrorTraceId: string | null;
271272
readonly connectionState: EnvironmentConnectionPhase;
273+
/** Waiting on the host to approve this device: say so plainly, not as a failure. */
274+
readonly awaitingApproval?: boolean;
272275
readonly disabled?: boolean;
273276
readonly errorExpanded: boolean;
274277
readonly label: string;
@@ -287,9 +290,10 @@ function CloudEnvironmentRowShell(props: {
287290
phase: props.connectionState,
288291
error: props.connectionError,
289292
traceId: props.connectionErrorTraceId,
293+
...(props.awaitingApproval ? { awaitingApproval: true as const } : {}),
290294
});
291295
const statusClassName =
292-
props.connectionError && props.connectionState !== "unsupported"
296+
props.connectionError && props.connectionState !== "unsupported" && !props.awaitingApproval
293297
? "text-danger-foreground"
294298
: "text-foreground-muted";
295299
const [errorMeasurement, setErrorMeasurement] = useState<{
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { AuthAccessWriteScope, type AuthPairingLink, type EnvironmentId } from "@t3tools/contracts";
2+
import { useEffect, useEffectEvent } from "react";
3+
4+
import * as Dialog from "../../components/ui/Dialog";
5+
import { authEnvironment } from "../../state/auth";
6+
import { useEnvironments } from "../../state/environments";
7+
import { useEnvironmentQuery } from "../../state/query";
8+
import { useEnvironmentsWithScope } from "../../state/session";
9+
import { useAtomCommand } from "../../state/use-atom-command";
10+
import {
11+
type ConnectDeviceApprovalPrompt,
12+
connectDeviceApprovalPrompts,
13+
createApprovalPromptQueue,
14+
} from "./connectDeviceApproval";
15+
16+
/** Asks to approve T3 Connect devices waiting on hosts that require it and this phone manages. */
17+
export function ConnectDeviceApprovalCoordinator() {
18+
const { environments } = useEnvironments();
19+
const requiring = environments.filter(
20+
(environment) =>
21+
environment.connection.phase === "connected" &&
22+
environment.serverConfig?.settings.requireConnectDeviceApproval === true,
23+
);
24+
const managed = useEnvironmentsWithScope(requiring, AuthAccessWriteScope);
25+
return (
26+
<>
27+
{requiring
28+
.filter((environment) => managed.has(environment.environmentId))
29+
.map((environment) => (
30+
<EnvironmentApprovalWatcher
31+
key={environment.environmentId}
32+
environmentId={environment.environmentId}
33+
environmentLabel={environment.label}
34+
/>
35+
))}
36+
</>
37+
);
38+
}
39+
40+
/** "gone": the request expired or was answered elsewhere before this answer arrived. */
41+
type ApprovalAnswer = "done" | "failed" | "gone";
42+
43+
interface QueuedApprovalPrompt extends ConnectDeviceApprovalPrompt {
44+
readonly environmentId: EnvironmentId;
45+
readonly approve: () => Promise<ApprovalAnswer>;
46+
readonly deny: () => Promise<ApprovalAnswer>;
47+
}
48+
49+
const watchedEnvironments = new Set<EnvironmentId>();
50+
51+
// One queue for every host: a second Android alert would replace an unanswered first one.
52+
const approvalPrompts = createApprovalPromptQueue<QueuedApprovalPrompt>((prompt, answer) => {
53+
const respond = (action: () => Promise<ApprovalAnswer>, failure: string) => () =>
54+
void action().then((outcome) => {
55+
// A host that went away or lost access cannot be retried; let the next prompt show.
56+
if (outcome === "done" || !watchedEnvironments.has(prompt.environmentId)) {
57+
return answer("done");
58+
}
59+
if (outcome === "gone") {
60+
return Dialog.alert(
61+
"Request no longer waiting",
62+
"It expired or was answered elsewhere.",
63+
[{ text: "OK", onPress: () => answer("done") }],
64+
{ onDismiss: () => answer("done") },
65+
);
66+
}
67+
const retryIfWatched = () =>
68+
answer(watchedEnvironments.has(prompt.environmentId) ? "retry" : "done");
69+
Dialog.alert(
70+
failure,
71+
"The request is still waiting. Try again.",
72+
[{ text: "OK", onPress: retryIfWatched }],
73+
// Another dialog took the screen; offer the request again on the next update.
74+
{ onDismiss: () => answer("later") },
75+
);
76+
});
77+
Dialog.alert(
78+
prompt.title,
79+
prompt.message,
80+
[
81+
{ text: "Deny", onPress: respond(prompt.deny, "Could not deny the device") },
82+
{ text: "Approve", onPress: respond(prompt.approve, "Could not approve the device") },
83+
],
84+
// Another dialog took the screen; offer the request again on the next update.
85+
{ cancelable: false, onDismiss: () => answer("later") },
86+
);
87+
});
88+
89+
function EnvironmentApprovalWatcher(props: {
90+
readonly environmentId: EnvironmentId;
91+
readonly environmentLabel: string;
92+
}) {
93+
const access = useEnvironmentQuery(
94+
authEnvironment.accessChanges({ environmentId: props.environmentId, input: null }),
95+
);
96+
const approve = useAtomCommand(authEnvironment.approveConnectDevice, { reportFailure: false });
97+
const deny = useAtomCommand(authEnvironment.revokePairingLink, { reportFailure: false });
98+
const pairingLinks =
99+
access.data?.type === "snapshot" ? access.data.payload.pairingLinks : undefined;
100+
101+
const offerPrompts = useEffectEvent((links: ReadonlyArray<AuthPairingLink>) => {
102+
const prompts = connectDeviceApprovalPrompts({
103+
environmentLabel: props.environmentLabel,
104+
pairingLinks: links,
105+
shownIds: approvalPrompts.known,
106+
});
107+
approvalPrompts.offer(
108+
prompts.map((prompt) => ({
109+
...prompt,
110+
environmentId: props.environmentId,
111+
approve: () =>
112+
approve({
113+
environmentId: props.environmentId,
114+
input: { pairingLinkId: prompt.pairingLinkId },
115+
}).then((result) =>
116+
result._tag !== "Success" ? "failed" : result.value.approved ? "done" : "gone",
117+
),
118+
deny: () =>
119+
deny({ environmentId: props.environmentId, input: { id: prompt.pairingLinkId } }).then(
120+
(result) =>
121+
result._tag !== "Success" ? "failed" : result.value.revoked ? "done" : "gone",
122+
),
123+
})),
124+
);
125+
});
126+
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this host with the module-level prompt queue and drops its prompts when it goes away
127+
useEffect(() => {
128+
const environmentId = props.environmentId;
129+
watchedEnvironments.add(environmentId);
130+
return () => {
131+
watchedEnvironments.delete(environmentId);
132+
approvalPrompts.drop((prompt) => prompt.environmentId === environmentId);
133+
};
134+
}, [props.environmentId]);
135+
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a new request on the access stream opens a native alert
136+
useEffect(() => {
137+
if (pairingLinks !== undefined) offerPrompts(pairingLinks);
138+
}, [pairingLinks]);
139+
140+
return null;
141+
}

‎apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import { View } from "react-native";
1010

1111
import { AppText as Text } from "../../components/ui/AppText";
1212

13-
function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string {
13+
function noticeTitle(
14+
phase: EnvironmentConnectionPhase,
15+
environmentLabel: string,
16+
awaitingApproval: boolean,
17+
): string {
18+
if (awaitingApproval) return `Waiting for ${environmentLabel} to approve this device`;
1419
switch (phase) {
1520
case "offline":
1621
return "You are offline";
@@ -33,7 +38,9 @@ function noticeDetail(
3338
phase: EnvironmentConnectionPhase,
3439
resourceName: string,
3540
error: string | null,
41+
awaitingApproval: boolean,
3642
): string {
43+
if (error && awaitingApproval) return error;
3744
if (error) {
3845
return phase === "reconnecting" ? `The app will keep retrying automatically. ${error}` : error;
3946
}
@@ -78,10 +85,19 @@ export function EnvironmentConnectionNotice(props: {
7885
)}
7986

8087
<Text className="text-center text-lg font-t3-bold text-foreground">
81-
{noticeTitle(props.connection.phase, props.environmentLabel)}
88+
{noticeTitle(
89+
props.connection.phase,
90+
props.environmentLabel,
91+
props.connection.awaitingApproval === true,
92+
)}
8293
</Text>
8394
<Text className="text-center text-sm leading-normal text-foreground-muted">
84-
{noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
95+
{noticeDetail(
96+
props.connection.phase,
97+
props.resourceName,
98+
props.connection.error,
99+
props.connection.awaitingApproval === true,
100+
)}
85101
{props.connection.traceId ? (
86102
<ConnectionTraceId traceId={props.connection.traceId} />
87103
) : null}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { AuthPairingLink } from "@t3tools/contracts";
2+
import * as DateTime from "effect/DateTime";
3+
import { describe, expect, it } from "vite-plus/test";
4+
5+
import { connectDeviceApprovalPrompts, createApprovalPromptQueue } from "./connectDeviceApproval";
6+
7+
const link = (id: string, createdAtMs: number, request?: AuthPairingLink["connectDeviceRequest"]) =>
8+
({
9+
id,
10+
scopes: [],
11+
subject: "cloud-connect",
12+
createdAt: DateTime.makeUnsafe(createdAtMs),
13+
expiresAt: DateTime.makeUnsafe(createdAtMs + 600_000),
14+
...(request ? { connectDeviceRequest: request } : {}),
15+
}) as unknown as AuthPairingLink;
16+
17+
describe("connectDeviceApprovalPrompts", () => {
18+
it("prompts once per waiting device, oldest first, and skips ordinary pairing links", () => {
19+
const pairingLinks = [
20+
link("newer", 2_000, { verificationCode: "WXYZ-2345" }),
21+
link("plain", 500),
22+
link("older", 1_000, {
23+
verificationCode: "ABCD-EFGH",
24+
client: { deviceType: "desktop", browser: "Chrome", os: "macOS" },
25+
}),
26+
];
27+
28+
const prompts = connectDeviceApprovalPrompts({
29+
environmentLabel: "Mac mini",
30+
pairingLinks,
31+
shownIds: new Set(["newer"]),
32+
});
33+
34+
expect(prompts.map((prompt) => prompt.pairingLinkId)).toEqual(["older"]);
35+
expect(prompts[0]?.message).toContain("Chrome on macOS wants to connect to Mac mini");
36+
expect(prompts[0]?.message).toContain("ABCD-EFGH");
37+
});
38+
});
39+
40+
describe("createApprovalPromptQueue", () => {
41+
it("shows one prompt at a time and offers a failed one again before the rest", () => {
42+
const shown: string[] = [];
43+
const answers: Array<(outcome: "done" | "retry" | "later") => void> = [];
44+
const queue = createApprovalPromptQueue<{ pairingLinkId: string }>((prompt, answer) => {
45+
shown.push(prompt.pairingLinkId);
46+
answers.push(answer);
47+
});
48+
49+
queue.offer([{ pairingLinkId: "a" }, { pairingLinkId: "b" }]);
50+
queue.offer([{ pairingLinkId: "a" }]);
51+
expect(shown).toEqual(["a"]);
52+
53+
answers.at(-1)!("retry");
54+
expect(shown).toEqual(["a", "a"]);
55+
answers.at(-1)!("done");
56+
expect(shown).toEqual(["a", "a", "b"]);
57+
});
58+
59+
it("waits for the next offer when another dialog replaced the prompt", () => {
60+
const shown: string[] = [];
61+
const answers: Array<(outcome: "done" | "retry" | "later") => void> = [];
62+
const queue = createApprovalPromptQueue<{ pairingLinkId: string }>((prompt, answer) => {
63+
shown.push(prompt.pairingLinkId);
64+
answers.push(answer);
65+
});
66+
67+
queue.offer([{ pairingLinkId: "a" }]);
68+
answers.at(-1)!("later");
69+
expect(shown).toEqual(["a"]);
70+
queue.offer([]);
71+
expect(shown).toEqual(["a", "a"]);
72+
});
73+
74+
it("offers a dropped host's on-screen prompt again once the host is back", () => {
75+
const shown: string[] = [];
76+
const answers: Array<(outcome: "done" | "retry" | "later") => void> = [];
77+
const queue = createApprovalPromptQueue<{ pairingLinkId: string; host: string }>(
78+
(prompt, answer) => {
79+
shown.push(prompt.pairingLinkId);
80+
answers.push(answer);
81+
},
82+
);
83+
84+
queue.offer([{ pairingLinkId: "a", host: "mini" }]);
85+
queue.drop((prompt) => prompt.host === "mini");
86+
answers.at(-1)!("retry");
87+
expect(shown).toEqual(["a"]);
88+
queue.offer([{ pairingLinkId: "a", host: "mini" }]);
89+
expect(shown).toEqual(["a", "a"]);
90+
});
91+
92+
it("drops a host's waiting prompts so they cannot hold up other hosts", () => {
93+
const shown: string[] = [];
94+
const answers: Array<(outcome: "done" | "retry" | "later") => void> = [];
95+
const queue = createApprovalPromptQueue<{ pairingLinkId: string; host: string }>(
96+
(prompt, answer) => {
97+
shown.push(prompt.pairingLinkId);
98+
answers.push(answer);
99+
},
100+
);
101+
102+
queue.offer([
103+
{ pairingLinkId: "a", host: "mini" },
104+
{ pairingLinkId: "b", host: "mini" },
105+
{ pairingLinkId: "c", host: "laptop" },
106+
]);
107+
queue.drop((prompt) => prompt.host === "mini");
108+
answers.at(-1)!("done");
109+
expect(shown).toEqual(["a", "c"]);
110+
// A dropped host's request can be offered again once the host is back.
111+
expect(queue.known.has("b")).toBe(false);
112+
});
113+
});

0 commit comments

Comments
 (0)