Skip to content

Commit eea7b35

Browse files
feat(web): revoking a T3 Connect device offers to turn on approval
With approval off, a revoked T3 Connect device signs straight back in. Revoking one from the devices list, from Authorized clients, or with Revoke others now asks first and offers "Require approval for new devices" (checked), saving it before the revoke. The CLI says the device can reconnect when approval is off. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent a44ad1d commit eea7b35

6 files changed

Lines changed: 200 additions & 13 deletions

File tree

‎apps/server/src/cli/auth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ const connectDeviceRevokeCommand = Command.make("revoke", {
224224
const outcome = yield* environmentAuth.revokeConnectDeviceByCode(flags.code);
225225
yield* Console.log(
226226
outcome === "revoked"
227-
? `Revoked ${flags.code}.\n`
227+
? `Revoked ${flags.code}. If approval for new devices is off, it can connect again on its own.\n`
228228
: outcome === "ambiguous"
229229
? `More than one trusted device shows ${flags.code}; revoke it from a client.\n`
230230
: `No trusted device shows ${flags.code}.\n`,

‎apps/web/src/components/settings/ConnectDeviceApprovalSettings.tsx‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { authEnvironment } from "~/state/auth";
1111
import { useAtomCommand } from "~/state/use-atom-command";
1212
import { Button } from "../ui/button";
1313
import { stackedThreadToast, toastManager } from "../ui/toast";
14-
import { describeConnectDeviceClient, formatAccessTimestamp } from "./ConnectionsSettings.logic";
14+
import {
15+
connectDeviceName,
16+
describeConnectDeviceClient,
17+
formatAccessTimestamp,
18+
} from "./ConnectionsSettings.logic";
1519
import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
1620
import { SettingsSection } from "./settingsLayout";
1721

@@ -32,11 +36,14 @@ export function ConnectDeviceApprovalSettings({
3236
requests,
3337
devices,
3438
canWrite,
39+
requestRevoke,
3540
}: {
3641
readonly environmentId: EnvironmentId;
3742
readonly requests: ReadonlyArray<ServerPairingLinkRecord>;
3843
readonly devices: ReadonlyArray<AuthConnectDevice>;
3944
readonly canWrite: boolean;
45+
/** Revokes now, or first asks to turn approval on when it is off. */
46+
readonly requestRevoke: (title: string, revoke: () => Promise<void>) => void;
4047
}) {
4148
const approve = useAtomCommand(authEnvironment.approveConnectDevice, { reportFailure: false });
4249
const deny = useAtomCommand(authEnvironment.revokePairingLink, { reportFailure: false });
@@ -124,13 +131,15 @@ export function ConnectDeviceApprovalSettings({
124131
variant="destructive-outline"
125132
disabled={!canWrite || busy}
126133
onClick={() =>
127-
void run(device.proofKeyThumbprint, async () =>
128-
reportFailure(
129-
"Could not revoke the device",
130-
await revoke({
131-
environmentId,
132-
input: { proofKeyThumbprint: device.proofKeyThumbprint },
133-
}),
134+
requestRevoke(`Revoke ${connectDeviceName(device)}?`, () =>
135+
run(device.proofKeyThumbprint, async () =>
136+
reportFailure(
137+
"Could not revoke the device",
138+
await revoke({
139+
environmentId,
140+
input: { proofKeyThumbprint: device.proofKeyThumbprint },
141+
}),
142+
),
134143
),
135144
)
136145
}

‎apps/web/src/components/settings/ConnectionsSettings.logic.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,15 @@ export function resolveAccessEnvironmentId(input: {
469469
);
470470
}
471471

472+
/** A short name for a T3 Connect device: its label, or what it runs on. */
473+
export function connectDeviceName({
474+
client,
475+
}: {
476+
readonly client?: AuthClientMetadata | undefined;
477+
}): string {
478+
return client?.label ?? client?.os ?? client?.browser ?? "this device";
479+
}
480+
472481
/** How a T3 Connect device describes itself. It can say anything, so the code is what to trust. */
473482
export function describeConnectDeviceClient({
474483
client,

‎apps/web/src/components/settings/ConnectionsSettings.tsx‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as Cause from "effect/Cause";
2929
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
3030
import {
3131
applyWslEnableSelection,
32+
connectDeviceName,
3233
isWslSettingsRowVisible,
3334
missingTailscaleEndpointDescription,
3435
resolveLocalVersionStatusLabel,
@@ -107,6 +108,7 @@ import {
107108
usePrimaryEnvironment,
108109
} from "~/state/environments";
109110
import { requestConfirmDialog } from "~/confirmDialog";
111+
import { useUpdateEnvironmentSettings } from "~/hooks/useSettings";
110112
import { useAtomCommand } from "../../state/use-atom-command";
111113
import { environmentIdsKey } from "~/state/environmentAtomList";
112114
import { environmentUpdateStatesAtom, serverEnvironment } from "~/state/server";
@@ -140,7 +142,13 @@ import {
140142
SavedBackendListRow,
141143
} from "./SavedEnvironmentRows";
142144
import { AddEnvironmentDialog } from "./AddEnvironmentDialog";
145+
import {
146+
type ConnectDeviceRevokeRequest,
147+
RevokeConnectDeviceDialog,
148+
} from "./RevokeConnectDeviceDialog";
143149

150+
// Sessions T3 Connect minted for a device signed in to the linked account.
151+
const CLOUD_CONNECT_SESSION_SUBJECT = "cloud-connect";
144152
const DEFAULT_TAILSCALE_SERVE_PORT = 443;
145153
const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
146154

@@ -719,6 +727,46 @@ function ConnectionsSettingsContent({
719727
authAccessChanges.data?.type === "snapshot"
720728
? (authAccessChanges.data.payload.connectDevices ?? null)
721729
: null;
730+
const updateAccessSettings = useUpdateEnvironmentSettings(accessEnvironmentId);
731+
const [pendingConnectRevoke, setPendingConnectRevoke] =
732+
useState<ConnectDeviceRevokeRequest | null>(null);
733+
// With approval off a revoked T3 Connect device signs straight back in, so offer to turn it
734+
// on. Older servers have no such setting, and revoking there goes ahead as before.
735+
const connectApprovalOff =
736+
accessEnvironment?.serverConfig?.settings.requireConnectDeviceApproval === false;
737+
const requestConnectRevoke = (title: string, revoke: () => Promise<void>) => {
738+
if (connectApprovalOff && canWriteAccess) setPendingConnectRevoke({ title, revoke });
739+
else void revoke();
740+
};
741+
const requireConnectApproval = () =>
742+
new Promise<boolean>((resolve) =>
743+
updateAccessSettings(
744+
{ requireConnectDeviceApproval: true },
745+
{ onSaved: () => resolve(true), onFailed: () => resolve(false) },
746+
),
747+
);
748+
const requestRevokeClientSession = (sessionId: ServerClientSessionRecord["sessionId"]) => {
749+
const session = desktopClientSessions.find((candidate) => candidate.sessionId === sessionId);
750+
if (session?.subject === CLOUD_CONNECT_SESSION_SUBJECT) {
751+
requestConnectRevoke(`Revoke ${connectDeviceName(session)}?`, () =>
752+
handleRevokeDesktopClientSession(sessionId),
753+
);
754+
} else {
755+
void handleRevokeDesktopClientSession(sessionId);
756+
}
757+
};
758+
const requestRevokeOtherClients = () => {
759+
const revokesConnectDevices =
760+
(connectDevices?.length ?? 0) > 0 ||
761+
desktopClientSessions.some(
762+
(session) => !session.current && session.subject === CLOUD_CONNECT_SESSION_SUBJECT,
763+
);
764+
if (revokesConnectDevices) {
765+
requestConnectRevoke("Revoke all other clients?", handleRevokeOtherDesktopClients);
766+
} else {
767+
void handleRevokeOtherDesktopClients();
768+
}
769+
};
722770
const tailscaleHttpsEndpoint = desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null;
723771
const visibleDesktopNetworkAdvertisedEndpoints = isLocalBackendNetworkAccessible
724772
? desktopAdvertisedEndpoints.filter((endpoint) => !isTailscaleHttpsEndpoint(endpoint))
@@ -1061,7 +1109,7 @@ function ConnectionsSettingsContent({
10611109
revokingPairingLinkId={revokingDesktopPairingLinkId}
10621110
revokingClientSessionId={revokingDesktopClientSessionId}
10631111
onRevokePairingLink={handleRevokeDesktopPairingLink}
1064-
onRevokeClientSession={handleRevokeDesktopClientSession}
1112+
onRevokeClientSession={requestRevokeClientSession}
10651113
canRevoke={canWriteAccess}
10661114
/>
10671115
);
@@ -1307,7 +1355,7 @@ function ConnectionsSettingsContent({
13071355
: null
13081356
}
13091357
isRevokingOtherClients={isRevokingOtherDesktopClients}
1310-
onRevokeOtherClients={handleRevokeOtherDesktopClients}
1358+
onRevokeOtherClients={requestRevokeOtherClients}
13111359
/>
13121360
) : undefined
13131361
}
@@ -1336,8 +1384,14 @@ function ConnectionsSettingsContent({
13361384
requests={connectDeviceRequests}
13371385
devices={connectDevices}
13381386
canWrite={canWriteAccess}
1387+
requestRevoke={requestConnectRevoke}
13391388
/>
13401389
) : null}
1390+
<RevokeConnectDeviceDialog
1391+
request={pendingConnectRevoke}
1392+
onClose={() => setPendingConnectRevoke(null)}
1393+
requireApproval={requireConnectApproval}
1394+
/>
13411395
<AlertDialog
13421396
open={pendingDesktopServerExposureMode !== null && canManageLocalBackend}
13431397
onOpenChange={(open) => {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useState } from "react";
2+
3+
import {
4+
AlertDialog,
5+
AlertDialogClose,
6+
AlertDialogDescription,
7+
AlertDialogFooter,
8+
AlertDialogHeader,
9+
AlertDialogPopup,
10+
AlertDialogTitle,
11+
} from "../ui/alert-dialog";
12+
import { Button } from "../ui/button";
13+
import { Checkbox } from "../ui/checkbox";
14+
import { Spinner } from "../ui/spinner";
15+
16+
export interface ConnectDeviceRevokeRequest {
17+
readonly title: string;
18+
/** Revokes the device; resolves once the attempt finished, failed or not. */
19+
readonly revoke: () => Promise<void>;
20+
}
21+
22+
/**
23+
* With approval off, a revoked T3 Connect device signs straight back in, so revoking offers to
24+
* turn approval on first.
25+
*/
26+
export function RevokeConnectDeviceDialog({
27+
request,
28+
onClose,
29+
requireApproval,
30+
}: {
31+
readonly request: ConnectDeviceRevokeRequest | null;
32+
readonly onClose: () => void;
33+
/** Saves the setting; resolves false when the save failed. */
34+
readonly requireApproval: () => Promise<boolean>;
35+
}) {
36+
return (
37+
<AlertDialog
38+
open={request !== null}
39+
onOpenChange={(open) => {
40+
if (!open) onClose();
41+
}}
42+
>
43+
{request ? (
44+
<RevokeConnectDeviceDialogPopup
45+
key={request.title}
46+
request={request}
47+
onClose={onClose}
48+
requireApproval={requireApproval}
49+
/>
50+
) : null}
51+
</AlertDialog>
52+
);
53+
}
54+
55+
function RevokeConnectDeviceDialogPopup({
56+
request,
57+
onClose,
58+
requireApproval,
59+
}: {
60+
readonly request: ConnectDeviceRevokeRequest;
61+
readonly onClose: () => void;
62+
readonly requireApproval: () => Promise<boolean>;
63+
}) {
64+
const [turnOnApproval, setTurnOnApproval] = useState(true);
65+
const [busy, setBusy] = useState(false);
66+
67+
const confirm = async () => {
68+
setBusy(true);
69+
if (turnOnApproval && !(await requireApproval())) {
70+
setBusy(false);
71+
return;
72+
}
73+
await request.revoke();
74+
setBusy(false);
75+
onClose();
76+
};
77+
78+
return (
79+
<AlertDialogPopup>
80+
<AlertDialogHeader>
81+
<AlertDialogTitle>{request.title}</AlertDialogTitle>
82+
<AlertDialogDescription>
83+
Approval for new devices is off, so a revoked device signed in to your T3 Connect account
84+
can connect again on its own.
85+
</AlertDialogDescription>
86+
<label className="mt-2 flex cursor-pointer items-start gap-3">
87+
<Checkbox
88+
className="mt-0.5"
89+
checked={turnOnApproval}
90+
disabled={busy}
91+
onCheckedChange={(checked) => setTurnOnApproval(checked === true)}
92+
/>
93+
<span className="min-w-0">
94+
<span className="block text-sm font-medium text-foreground">
95+
Require approval for new devices
96+
</span>
97+
<span className="block text-xs text-muted-foreground">
98+
The device has to be approved here before it can connect again.
99+
</span>
100+
</span>
101+
</label>
102+
</AlertDialogHeader>
103+
<AlertDialogFooter>
104+
<AlertDialogClose disabled={busy} render={<Button variant="outline" disabled={busy} />}>
105+
Cancel
106+
</AlertDialogClose>
107+
<Button variant="destructive" disabled={busy} onClick={() => void confirm()}>
108+
{busy && <Spinner size="sm" />}
109+
Revoke
110+
</Button>
111+
</AlertDialogFooter>
112+
</AlertDialogPopup>
113+
);
114+
}

‎docs/user/remote-access.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ headless host, run `t3 auth connect-device list` and then
5555
and the device asks again when it retries.
5656

5757
Each device is approved once. Revoke it from **T3 Connect devices** in the same
58-
settings page, or with `t3 auth connect-device revoke <code>`, and it has to ask
59-
again. Devices that connected through T3 Connect while approval was off stay
58+
settings page, or with `t3 auth connect-device revoke <code>`. With approval on,
59+
it has to ask again. With approval off, it can connect again on its own, so
60+
revoking in the app offers to turn approval on first. Devices that connected through T3 Connect while approval was off stay
6061
approved and appear in that list for you to review. A device that has not
6162
connected since you updated T3 Code asks for approval like a new one. Older apps
6263
cannot show the code, so update them before you turn approval on.

0 commit comments

Comments
 (0)