Files
inkwell/frontend/src/views/AccountView.vue
T
bvandeusenandClaude Opus 5.5 498b23789c DRY pass #3: one one-time secret reveal (#5373)
AccountView's device-token reveal was OneTimeLink's markup and copy handler
again. OneTimeLink becomes OneTimeSecret (value, note, noun = link | token),
and the account page shows a new token through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:18 -04:00

251 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 { useSessionStore } from "../stores/session";
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 OneTimeSecret from "../components/OneTimeSecret.vue";
import PageHeader from "../components/PageHeader.vue";
import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime";
import { repo } from "../adapters";
import type { StorageUse } from "../adapters/repo";
import { useAction } from "../composables/useAction";
import { roughSize } from "../size";
// The signed-in person's own account (not admin): the native clients linked to it
// (the desktop and Android apps sync with a device bearer token issued here), its
// password, and signing out everywhere else (family idea #5105, practice 4).
const devices = useDevicesStore();
const session = useSessionStore();
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("");
async function load() {
error.value = "";
try {
await config.load();
await devices.load();
} catch {
error.value = "Couldn't load your linked devices.";
}
// Separately, so a storage read that fails doesn't read as a devices failure.
try {
storage.value = await repo.auth.storage();
} catch {
storage.value = null;
}
}
// Attachment storage (#2939 §4): how much this account uses, against its limit.
const storage = ref<StorageUse | null>(null);
function storageLine(use: StorageUse): string {
const used = roughSize(use.used_bytes, "GB");
return use.limit_bytes === null ? `${used} used` : `${used} of ${roughSize(use.limit_bytes, "GB")} used`;
}
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 = errorMessage(e, "Couldn't create a device token.");
} finally {
creating.value = false;
}
}
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.");
}
}
const currentPassword = ref("");
const newPassword = ref("");
const changing = ref(false);
const passwordError = ref("");
const { busy: signingOut, act } = useAction();
function unlinkedNote(n: number): string {
if (n === 0) return "";
return n === 1 ? " 1 device was unlinked." : ` ${n} devices were unlinked.`;
}
async function changePassword() {
changing.value = true;
passwordError.value = "";
try {
const unlinked = await session.changePassword(currentPassword.value, newPassword.value);
currentPassword.value = "";
newPassword.value = "";
ui.showToast(`Password changed. You're signed out everywhere else.${unlinkedNote(unlinked)}`);
await devices.load();
} catch (e) {
passwordError.value = errorMessage(e, "Couldn't change your password.");
} finally {
changing.value = false;
}
}
async function signOutElsewhere() {
const ok = window.confirm(
"Sign out of every other browser and unlink every device? Each app will need to sign in again.",
);
if (!ok) return;
await act(async () => {
const unlinked = await session.signOutElsewhere();
ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`);
await devices.load();
}, "Couldn't sign out everywhere else.");
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page-shell">
<PageHeader title="Account" />
<h2 class="mb-2 section-label">Linked devices</h2>
<p class="mb-6 max-w-xl hint">
Link the Inkwell 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>
<!-- Every client this server holds, the one that fits this machine on top.
Hides itself when the server holds none. -->
<ClientDownloads />
<OneTimeSecret
v-if="freshToken"
class="mb-6"
:value="freshToken"
noun="token"
note="This device's token."
@done="freshToken = ''"
/>
<!-- 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 form-error">{{ 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="hint">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="row-card"
>
<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 {{ formatDateTime(d.created_at) }} · last synced {{ formatDateTime(d.last_used_at) }}
</p>
</div>
</div>
<button
type="button"
class="shrink-0 btn-sm-danger"
@click="revoke(d.id, d.name)"
>
Revoke
</button>
</li>
</ul>
<template v-if="storage">
<h2 class="mb-2 mt-10 section-label">Storage</h2>
<div
class="row-card"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Attachments</p>
<p class="text-xs text-neutral-400">Trash counts until it's emptied</p>
</div>
<p class="shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-300">{{ storageLine(storage) }}</p>
</div>
</template>
<h2 class="mb-2 mt-10 section-label">Password</h2>
<p class="mb-4 hint">Changing it signs you out everywhere else.</p>
<form class="flex max-w-sm flex-col gap-3" @submit.prevent="changePassword">
<BaseInput
id="current-password"
v-model="currentPassword"
type="password"
label="Current password"
autocomplete="current-password"
required
/>
<BaseInput
id="new-password"
v-model="newPassword"
type="password"
label="New password"
autocomplete="new-password"
:placeholder="`At least ${config.minPasswordLength} characters`"
:error="passwordError"
required
/>
<div>
<BaseButton type="submit" :loading="changing">Change password</BaseButton>
</div>
</form>
<h2 class="mb-2 mt-10 section-label">Sessions</h2>
<div
class="row-card"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Sign out everywhere else</p>
<p class="text-xs text-neutral-400">Every other browser, and every linked device</p>
</div>
<BaseButton variant="ghost" class="shrink-0" :loading="signingOut" @click="signOutElsewhere">
Sign out
</BaseButton>
</div>
</div>
</template>