fix(vpn): the reset must never be conditional on the state being sane

'Forget all connections' was shown only when registered || connected ||
profiles.length > 0 — evidence that something had worked.

A half-finished sign-in leaves none of those true. The core reaches NeedsLogin, no
profile is persisted, nothing connects — and the only control that could recover the app
was hidden, because it was reading the very state that had broken. The screen said
'Waiting for sign-in' with no way to choose another server, enter another key, or start
over. Deleting the app from the home screen was the only exit, on a real phone, at one
in the morning.

The comment above that gate already described the same mistake in a different case,
which is the tell: every condition here is a bet that the app knows its own state, and
this button exists for the times it does not. So there is no condition now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:59:45 +01:00
co-authored by Claude Opus 5
parent e3024a7818
commit 7672cf57bb
+19 -14
View File
@@ -1325,12 +1325,20 @@ export const VpnScreen = ({ embedded, onShowCredits, wordmark, wordmarkLight }:
What belongs here is the whole-app reset: forget every connection at once.
Gated on anything indicating a node exists, NOT on `registered` alone. That flag is written
by the app and stays false on the Tailscale path, so a fully connected node offered no
reset at all — invisible precisely when it applied.
NOT GATED AT ALL, and that is the point.
It was shown only when `registered || connected || profiles.length > 0` — evidence that
something had worked. But a reset is what you reach for when nothing has worked, and the
state it was reading is exactly the state that breaks: a half-finished sign-in leaves no
profile, no registration and no connection, so all three were false and the only way out
of the app was to delete it from the home screen.
That happened, on a real phone, at one in the morning. The previous version of this gate
had the same shape and the comment above it said the same thing about a different case,
which is the tell: any condition here is a bet that the app knows its own state, and this
button exists for when it does not.
*/}
{vm.registered || vm.connected || vm.profiles.length > 0 ? (
<Pressable
<Pressable
// Close Settings on the way out, so signing out lands on the first-run chooser rather
// than on a settings screen describing a node that no longer exists.
onPress={async () => {
@@ -1349,15 +1357,12 @@ export const VpnScreen = ({ embedded, onShowCredits, wordmark, wordmarkLight }:
<Text className="text-base font-bold" style={{ color: palette.danger }}>
{vm.busy ? 'Forgetting…' : 'Forget all connections'}
</Text>
</Pressable>
) : null}
{vm.registered || vm.connected || vm.profiles.length > 0 ? (
<Text style={{ color: palette.text, fontSize: 12 }}>
Removes this device from every tailnet it has joined and returns OffScale to setup. Each
one disappears from its admin panel; signing in again registers a new device. To leave
just one, sign out of it from its row on the main screen.
</Text>
) : null}
</Pressable>
<Text style={{ color: palette.text, fontSize: 12 }}>
Removes this device from every tailnet it has joined and returns OffScale to setup. Each
one disappears from its admin panel; signing in again registers a new device. To leave
just one, sign out of it from its row on the main screen.
</Text>
</>
}
>