sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Unlink was local-only. It cleared the server URL, token and cursor from the device, and left the bearer token valid on the server indefinitely — so someone who unlinked because the laptop was being sold or handed on believed they had revoked access when they hadn't. The blocker was identification, not intent: a token pasted from the web app never carried a device id, and /api/auth/me describes the user, not the device row, so DELETE /devices/<id> could only ever have worked for one of the two ways this app can be linked. DELETE /api/auth/devices/self keys off the token in the Authorization header instead, which the caller always holds — one route that works for both paths, owner-scoped like the rest, and no local schema change. Unlinking is never blocked on the network. Wanting to stop syncing is a local decision, so the revoke is attempted first, its outcome carried back, and the link cleared either way. When the token survives — server unreachable, or older than the route — the Sync screen says so in place, with where to revoke it. A toast would have been the wrong shape for that: it disappears, and this is exactly what someone returns to the screen to check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
updates as updateBridge,
|
||||
type Compatibility,
|
||||
type ProbeResult,
|
||||
type RevokeOutcome,
|
||||
type SyncStatus,
|
||||
type UpdateChannel,
|
||||
type UpdateStatus,
|
||||
@@ -45,6 +46,9 @@ const syncing = ref(false);
|
||||
const syncError = ref("");
|
||||
const lastResult = ref("");
|
||||
|
||||
/** Set only when unlinking left the token valid server-side (see revokeWarning). */
|
||||
const unlinkWarning = ref("");
|
||||
|
||||
const linked = computed(() => status.value?.linked === true);
|
||||
|
||||
/** Only offer to connect once a probe has said the server is usable. */
|
||||
@@ -205,20 +209,41 @@ async function syncNow() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advice for an unlink whose server-side revoke didn't land — empty when it did.
|
||||
* Never a toast: a toast disappears, and "your token is still live" is exactly the
|
||||
* kind of thing someone comes back to this screen to check.
|
||||
*/
|
||||
function revokeWarning(outcome: RevokeOutcome): string {
|
||||
if (outcome.status === "unsupported") {
|
||||
return "This server is older than in-app sign-out, so this device's token had to be left in place. Revoke it in the web app under Account → Linked devices.";
|
||||
}
|
||||
if (outcome.status === "failed") {
|
||||
return `${outcome.reason} Until it's revoked, this device's token still works — you can revoke it in the web app under Account → Linked devices.`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. The device token remains valid until you revoke it on the server under Account → Linked devices.",
|
||||
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. This device's access token is revoked, so it can't be used to reach the server again.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
status.value = await syncBridge.unlink();
|
||||
const result = await syncBridge.unlink();
|
||||
status.value = result.status;
|
||||
linkedAs.value = "";
|
||||
degraded.value = [];
|
||||
lastResult.value = "";
|
||||
ui.showToast("Disconnected. This device now works offline only.");
|
||||
unlinkWarning.value = revokeWarning(result.revoked);
|
||||
ui.showToast(
|
||||
result.revoked.status === "revoked"
|
||||
? "Disconnected, and this device is signed out on the server."
|
||||
: "Disconnected. This device now works offline only.",
|
||||
);
|
||||
} catch (e) {
|
||||
ui.showToast(String((e as Error)?.message ?? e));
|
||||
}
|
||||
@@ -292,6 +317,20 @@ onMounted(refresh);
|
||||
|
||||
<!-- Not linked: the normal resting state, deliberately not framed as a problem -->
|
||||
<template v-else>
|
||||
<!-- The one exception to that framing: an unlink whose server-side revoke
|
||||
didn't land leaves a live credential behind, and the person who unlinked
|
||||
to retire a machine has to be told plainly rather than by a toast. -->
|
||||
<section
|
||||
v-if="unlinkWarning"
|
||||
class="mb-6 rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10"
|
||||
role="alert"
|
||||
>
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
This device's token is still valid on the server
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300/80">{{ unlinkWarning }}</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
|
||||
aria-live="polite"
|
||||
|
||||
Reference in New Issue
Block a user