Files
thoughtsync/frontend/src/views/AccountView.vue
T
bvandeusenandClaude Opus 5 fd1e4ae487
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
downloads: five clients, and the page leads with the one that fits you
The Account page offered the APK and nothing else, because the APK was all
the server held. Step 3 baked in four more, so the single card had to become
a section — and five artifacts is exactly where a downloads page turns into
a table of filenames and stops being a product.

So it LEADS with what fits the machine asking, from the user agent, and keeps
the rest quiet but visible. A wrong guess costs nothing: nothing is behind a
disclosure and every other client is one click away.

Linux gets all three at once, because the UA says "Linux" and nothing about
dpkg or pacman — there is no better answer available. They are named for the
distro rather than the package format, since a person knows which system they
run and not necessarily which packaging it uses. The AppImage carries one
clause of its own: it is 95 MB against 3, and it is also the only bundle that
updates itself in place. Both facts belong to the same decision.

macOS and iOS lead with nothing and say so. There is no build for either, and
"There's no macOS build yet" is the difference between deliberate and broken.

The version renders `unknown` rather than blank, and the download stays
offered — not knowing which build it is, is not a reason to withhold it.

Two things this did NOT do, both deliberate:

The task asked for a Tauri case — do not offer the desktop app to someone
already running it. That case cannot be reached: `/account` redirects to the
board in the desktop app (requiresServer, router/index.ts), because device
tokens are a server-side concept. A branch for it would be dead code.

`.btn-link` mirrors BaseButton's declarations rather than replacing them.
BaseButton is a <button> and cannot carry an href, and unifying the two would
have put every button in the app into an operator pass that CI cannot check —
for a cosmetic gain. The comment names the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 07:58:17 -04:00

244 lines
8.1 KiB
Vue

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useConfigStore } from "../stores/config";
import { useDevicesStore } from "../stores/devices";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue";
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
// Per-user (not admin) management of linked native clients — the Tauri desktop and
// Android apps authenticate sync with a device bearer token issued here.
const devices = useDevicesStore();
const ui = useUiStore();
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
// component that fetches its own config would race the one that does.
const config = useConfigStore();
const error = ref("");
const newName = ref("");
const creating = ref(false);
// The freshly-issued plaintext token — shown ONCE (never retrievable again).
const freshToken = ref("");
// Desktop-app self-integration (Linux AppImage only; the card is hidden otherwise).
const desktopStatus = ref<IntegrationStatus | null>(null);
const desktopBusy = ref(false);
async function load() {
error.value = "";
try {
await config.load();
await devices.load();
} catch {
error.value = "Couldn't load your linked devices.";
}
}
async function link() {
creating.value = true;
error.value = "";
freshToken.value = "";
try {
freshToken.value = await devices.create(newName.value.trim() || "Device");
newName.value = "";
} catch (e) {
error.value = (e as { error?: string }).error ?? "Couldn't create a device token.";
} finally {
creating.value = false;
}
}
async function copyToken() {
try {
await navigator.clipboard.writeText(freshToken.value);
ui.showToast("Token copied to clipboard.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(id: string, name: string) {
if (!window.confirm(`Revoke "${name}"? That device will need to link again to sync.`)) return;
try {
await devices.revoke(id);
} catch {
ui.showToast("Couldn't revoke that device.");
}
}
function fmt(iso: string | null): string {
if (!iso) return "never";
return new Date(iso).toLocaleString();
}
async function loadDesktop() {
if (!isDesktop()) return;
try {
desktopStatus.value = await desktopBridge.integrationStatus();
} catch {
desktopStatus.value = null;
}
}
async function toggleIntegration() {
desktopBusy.value = true;
try {
desktopStatus.value = desktopStatus.value?.is_integrated
? await desktopBridge.unintegrate()
: await desktopBridge.integrate();
ui.showToast(
desktopStatus.value?.is_integrated
? "Added to your applications menu."
: "Removed from your applications menu.",
);
} catch (e) {
ui.showToast((e as { message?: string }).message ?? "Couldn't update the menu entry.");
} finally {
desktopBusy.value = false;
}
}
onMounted(() => {
void load();
void loadDesktop();
});
</script>
<template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
<header class="mb-8 flex items-center gap-3">
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
<svg
class="h-[18px] w-[18px]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</RouterLink>
<h1 class="text-xl font-bold tracking-tight">Linked devices</h1>
</header>
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
Link the ThoughtSync desktop or mobile app to sync your notes. Create a device token here, then
paste it into the app when it asks to connect. You can revoke a device at any time.
</p>
<!-- Desktop app self-integration (Linux AppImage only) -->
<section
v-if="desktopStatus?.is_appimage"
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Desktop app</p>
<p class="mt-0.5 text-xs text-neutral-400">
{{
desktopStatus.is_integrated
? "ThoughtSync is in your applications menu."
: "Add ThoughtSync to your applications menu to launch it like an installed app."
}}
</p>
</div>
<BaseButton
:variant="desktopStatus.is_integrated ? 'ghost' : 'primary'"
:loading="desktopBusy"
class="shrink-0"
@click="toggleIntegration"
>
{{ desktopStatus.is_integrated ? "Remove" : "Add to applications" }}
</BaseButton>
</section>
<!-- Every client this server holds, the one that fits this machine on top.
Hides itself when the server holds none. -->
<ClientDownloads />
<!-- One-time token reveal -->
<div
v-if="freshToken"
class="mb-6 rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Copy this token now it won't be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshToken }}</code
>
<button
type="button"
class="icon-btn shrink-0"
title="Copy token"
aria-label="Copy token"
@click="copyToken"
>
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshToken = ''"
>
Done
</button>
</div>
<!-- Create a device token -->
<form class="mb-8 flex items-end gap-3" @submit.prevent="link">
<BaseInput
id="device-name"
v-model="newName"
label="Link a new device"
placeholder="e.g. My laptop, Pixel phone"
class="flex-1"
/>
<BaseButton type="submit" :loading="creating">Create token</BaseButton>
</form>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<!-- Device list -->
<div v-if="devices.loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
<div
v-else-if="!devices.items.length"
class="rounded-xl border border-dashed border-neutral-300 py-10 text-center dark:border-neutral-700"
>
<p class="text-sm text-neutral-500 dark:text-neutral-400">No devices linked yet.</p>
</div>
<ul v-else class="flex flex-col gap-2">
<li
v-for="d in devices.items"
:key="d.id"
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="flex min-w-0 items-center gap-3">
<span class="text-neutral-400"><Icon name="device" /></span>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ d.name }}</p>
<p class="text-xs text-neutral-400">
Linked {{ fmt(d.created_at) }} · last synced {{ fmt(d.last_used_at) }}
</p>
</div>
</div>
<button
type="button"
class="shrink-0 rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
@click="revoke(d.id, d.name)"
>
Revoke
</button>
</li>
</ul>
</div>
</template>