CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 8s
CI & Build / Web typecheck and unit tests (push) Failing after 11s
CI & Build / Python tests (push) Successful in 19s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 44s
CI & Build / integration (push) Successful in 1m32s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m11s
Android / Build the server image (push) Successful in 1s
composables/useAction.ts: toastOnFailure runs an action and toasts the server's reason or a fallback; useAction adds the busy flag a button waits on; useRowAction keeps the id of the row whose action is running. Import, export, the menu entry, the integration prompt, sign out elsewhere, the account reset link, the group actions (whose local act() it replaces), invite revoke and sync disconnect each wrote that try/catch/finally out. Kept: AccountView's device revoke. It shows a fixed message rather than the server's reason, and moving it would change the text. ShareDialog shows its errors inline, not in a toast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
9.3 KiB
Vue
286 lines
9.3 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 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";
|
|
|
|
// 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 gigabytes(bytes: number): string {
|
|
const gb = bytes / 1024 ** 3;
|
|
return `${gb < 10 ? gb.toFixed(1) : gb.toFixed(0)} GB`;
|
|
}
|
|
|
|
function storageLine(use: StorageUse): string {
|
|
const used = gigabytes(use.used_bytes);
|
|
return use.limit_bytes === null ? `${used} used` : `${used} of ${gigabytes(use.limit_bytes)} 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 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.");
|
|
}
|
|
}
|
|
|
|
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 />
|
|
|
|
<!-- 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 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>
|