Account page: change your password, or sign out everywhere else
Family idea #5105, practice 4. Either action signs the account out of every other browser and unlinks every device. The browser that made the change stays signed in. - POST /api/auth/password needs the current password. A wrong one returns 403, not 401, so this browser doesn't read as signed out, and it counts against the sign-in throttle. A short new password returns 400. - POST /api/auth/sign-out-elsewhere does the same sign-out without a password change. Called from a device, it keeps that device linked. - _sign_out_elsewhere moves session_epoch on and deletes device tokens. The reset route now uses it too, keeping no device. - The page is renamed from "Linked devices" to "Account", in the router title and both nav entries. Its sections are Linked devices, Password (one short line, then the form) and Sessions (a single "Sign out everywhere else" row in the device rows' style), per preference 188: one line each, no paragraphs. - docs/public-hosting.md says how sessions end, and why a browser session isn't listed the way a device is: it is a signed cookie, ended by moving the epoch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ export const local: Repo = {
|
||||
login: () => Promise.reject(new Error("You're offline — there's no account to sign in to.")),
|
||||
register: () => Promise.reject(new Error("You're offline — accounts are created on a server.")),
|
||||
logout: () => Promise.resolve(),
|
||||
changePassword: () => Promise.reject(new Error(NEEDS_SERVER)),
|
||||
signOutElsewhere: () => Promise.reject(new Error(NEEDS_SERVER)),
|
||||
},
|
||||
|
||||
devices: {
|
||||
|
||||
@@ -105,6 +105,15 @@ export interface AuthRepo {
|
||||
* while registration is closed. */
|
||||
register(email: string, password: string, displayName: string, invite?: string): Promise<User>;
|
||||
logout(): Promise<void>;
|
||||
/** Signs the account out of every other browser and unlinks every device; this
|
||||
* browser stays signed in. Rejects with a 403 when `current` is wrong. */
|
||||
changePassword(current: string, next: string): Promise<SignedOutElsewhere>;
|
||||
/** The same sign-out as `changePassword`, without changing the password. */
|
||||
signOutElsewhere(): Promise<SignedOutElsewhere>;
|
||||
}
|
||||
|
||||
export interface SignedOutElsewhere {
|
||||
devices_unlinked: number;
|
||||
}
|
||||
|
||||
export interface DevicesRepo {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
ChecklistItemChanges,
|
||||
Repo,
|
||||
ServerSetting,
|
||||
SignedOutElsewhere,
|
||||
} from "./repo";
|
||||
|
||||
// Render a board query to the GET /api/notes query string. Mirrors the param
|
||||
@@ -58,6 +59,9 @@ export const rest: Repo = {
|
||||
register: (email, password, displayName, invite) =>
|
||||
api.post<User>("/api/auth/register", { email, password, display_name: displayName, invite }),
|
||||
logout: () => api.post<void>("/api/auth/logout"),
|
||||
changePassword: (current, next) =>
|
||||
api.post<SignedOutElsewhere>("/api/auth/password", { current_password: current, new_password: next }),
|
||||
signOutElsewhere: () => api.post<SignedOutElsewhere>("/api/auth/sign-out-elsewhere"),
|
||||
},
|
||||
|
||||
devices: {
|
||||
|
||||
@@ -346,8 +346,8 @@ async function signOut() {
|
||||
v-if="!desktopApp"
|
||||
to="/account"
|
||||
class="icon-btn hidden sm:inline-flex"
|
||||
title="Linked devices"
|
||||
aria-label="Linked devices"
|
||||
title="Account"
|
||||
aria-label="Account"
|
||||
>
|
||||
<Icon name="device" />
|
||||
</RouterLink>
|
||||
@@ -458,7 +458,7 @@ async function signOut() {
|
||||
>
|
||||
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
|
||||
<RouterLink to="/account" class="nav-link" exact-active-class="nav-link-active">
|
||||
<Icon name="device" /> Linked devices
|
||||
<Icon name="device" /> Account
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="session.user?.is_admin"
|
||||
|
||||
@@ -48,7 +48,8 @@ const router = createRouter({
|
||||
meta: { title: "Sync", requiresAuth: true, requiresDesktop: true },
|
||||
},
|
||||
{
|
||||
// Per-user account: linked devices (native-client sync tokens). Any user.
|
||||
// Per-user account: linked devices (native-client sync tokens), the password,
|
||||
// and signing out everywhere else. Any user.
|
||||
//
|
||||
// The mirror of `requiresDesktop` above: this one needs a SERVER. The desktop
|
||||
// is itself one of the devices this page lists, so offline the list is always
|
||||
@@ -58,7 +59,7 @@ const router = createRouter({
|
||||
path: "/account",
|
||||
name: "account",
|
||||
component: () => import("../views/AccountView.vue"),
|
||||
meta: { title: "Linked devices", requiresAuth: true, requiresServer: true },
|
||||
meta: { title: "Account", requiresAuth: true, requiresServer: true },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
|
||||
@@ -37,5 +37,15 @@ export const useSessionStore = defineStore("session", () => {
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
return { user, loaded, fetchMe, login, register, logout };
|
||||
// Both end every other session and unlink every device, and keep this one: the
|
||||
// signed-in user doesn't change. Each resolves to how many devices were unlinked.
|
||||
async function changePassword(current: string, next: string): Promise<number> {
|
||||
return (await repo.auth.changePassword(current, next)).devices_unlinked;
|
||||
}
|
||||
|
||||
async function signOutElsewhere(): Promise<number> {
|
||||
return (await repo.auth.signOutElsewhere()).devices_unlinked;
|
||||
}
|
||||
|
||||
return { user, loaded, fetchMe, login, register, logout, changePassword, signOutElsewhere };
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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";
|
||||
@@ -11,9 +12,11 @@ import PageHeader from "../components/PageHeader.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
|
||||
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
||||
// Android apps authenticate sync with a device bearer token issued here.
|
||||
// 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.
|
||||
@@ -68,6 +71,50 @@ async function revoke(id: string, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const changing = ref(false);
|
||||
const passwordError = ref("");
|
||||
const signingOut = ref(false);
|
||||
|
||||
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;
|
||||
signingOut.value = true;
|
||||
try {
|
||||
const unlinked = await session.signOutElsewhere();
|
||||
ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`);
|
||||
await devices.load();
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't sign out everywhere else."));
|
||||
} finally {
|
||||
signingOut.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
@@ -75,8 +122,9 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<PageHeader title="Linked devices" />
|
||||
<PageHeader title="Account" />
|
||||
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked devices</h2>
|
||||
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
|
||||
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.
|
||||
@@ -164,5 +212,44 @@ onMounted(() => {
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="mb-2 mt-10 text-xs font-semibold uppercase tracking-wide text-neutral-400">Password</h2>
|
||||
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">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 8 characters"
|
||||
:error="passwordError"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<BaseButton type="submit" :loading="changing">Change password</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2 class="mb-2 mt-10 text-xs font-semibold uppercase tracking-wide text-neutral-400">Sessions</h2>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user