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>
251 lines
8.1 KiB
Vue
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>
|