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:
2026-10-08 10:22:16 -04:00
co-authored by Claude Opus 5.5
parent 1dd6fc1e20
commit bb591871a4
10 changed files with 293 additions and 13 deletions
+6
View File
@@ -113,6 +113,12 @@ docker run --rm -v inkwell-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz
on a note shared with you can't run script in your session. on a note shared with you can't run script in your session.
- **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in - **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in
for CSRF protection: a `Lax` cookie is not sent on a cross-site POST. for CSRF protection: a `Lax` cookie is not sent on a cross-site POST.
- **Sessions can be ended from the Account page.** Changing your password there needs
the current one. It signs you out of every other browser and unlinks every app,
and you stay signed in where you made the change. **Sign out everywhere else**
does the same without changing the password. A browser session doesn't appear in
a list the way a linked app does: it is a signed cookie in that browser, ended by
moving the account on rather than by deleting a row.
## Email and forgotten passwords ## Email and forgotten passwords
+2
View File
@@ -29,6 +29,8 @@ export const local: Repo = {
login: () => Promise.reject(new Error("You're offline — there's no account to sign in to.")), 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.")), register: () => Promise.reject(new Error("You're offline — accounts are created on a server.")),
logout: () => Promise.resolve(), logout: () => Promise.resolve(),
changePassword: () => Promise.reject(new Error(NEEDS_SERVER)),
signOutElsewhere: () => Promise.reject(new Error(NEEDS_SERVER)),
}, },
devices: { devices: {
+9
View File
@@ -105,6 +105,15 @@ export interface AuthRepo {
* while registration is closed. */ * while registration is closed. */
register(email: string, password: string, displayName: string, invite?: string): Promise<User>; register(email: string, password: string, displayName: string, invite?: string): Promise<User>;
logout(): Promise<void>; 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 { export interface DevicesRepo {
+4
View File
@@ -22,6 +22,7 @@ import type {
ChecklistItemChanges, ChecklistItemChanges,
Repo, Repo,
ServerSetting, ServerSetting,
SignedOutElsewhere,
} from "./repo"; } from "./repo";
// Render a board query to the GET /api/notes query string. Mirrors the param // 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) => register: (email, password, displayName, invite) =>
api.post<User>("/api/auth/register", { email, password, display_name: displayName, invite }), api.post<User>("/api/auth/register", { email, password, display_name: displayName, invite }),
logout: () => api.post<void>("/api/auth/logout"), 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: { devices: {
+3 -3
View File
@@ -346,8 +346,8 @@ async function signOut() {
v-if="!desktopApp" v-if="!desktopApp"
to="/account" to="/account"
class="icon-btn hidden sm:inline-flex" class="icon-btn hidden sm:inline-flex"
title="Linked devices" title="Account"
aria-label="Linked devices" aria-label="Account"
> >
<Icon name="device" /> <Icon name="device" />
</RouterLink> </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> <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"> <RouterLink to="/account" class="nav-link" exact-active-class="nav-link-active">
<Icon name="device" /> Linked devices <Icon name="device" /> Account
</RouterLink> </RouterLink>
<RouterLink <RouterLink
v-if="session.user?.is_admin" v-if="session.user?.is_admin"
+3 -2
View File
@@ -48,7 +48,8 @@ const router = createRouter({
meta: { title: "Sync", requiresAuth: true, requiresDesktop: true }, 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 // 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 // is itself one of the devices this page lists, so offline the list is always
@@ -58,7 +59,7 @@ const router = createRouter({
path: "/account", path: "/account",
name: "account", name: "account",
component: () => import("../views/AccountView.vue"), component: () => import("../views/AccountView.vue"),
meta: { title: "Linked devices", requiresAuth: true, requiresServer: true }, meta: { title: "Account", requiresAuth: true, requiresServer: true },
}, },
{ {
path: "/login", path: "/login",
+11 -1
View File
@@ -37,5 +37,15 @@ export const useSessionStore = defineStore("session", () => {
user.value = null; 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 };
}); });
+90 -3
View File
@@ -2,6 +2,7 @@
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import { useDevicesStore } from "../stores/devices"; import { useDevicesStore } from "../stores/devices";
import { useSessionStore } from "../stores/session";
import { useUiStore } from "../stores/ui"; import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue"; import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue"; import BaseInput from "../components/BaseInput.vue";
@@ -11,9 +12,11 @@ import PageHeader from "../components/PageHeader.vue";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime"; import { formatDateTime } from "../notes/datetime";
// Per-user (not admin) management of linked native clients — the Tauri desktop and // The signed-in person's own account (not admin): the native clients linked to it
// Android apps authenticate sync with a device bearer token issued here. // (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 devices = useDevicesStore();
const session = useSessionStore();
const ui = useUiStore(); const ui = useUiStore();
// Loaded here rather than in ClientDownloads: this view already awaits it, and a // 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. // 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(() => { onMounted(() => {
void load(); void load();
}); });
@@ -75,8 +122,9 @@ onMounted(() => {
<template> <template>
<div class="mx-auto min-h-full max-w-2xl px-4 py-8"> <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"> <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 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. paste it into the app when it asks to connect. You can revoke a device at any time.
@@ -164,5 +212,44 @@ onMounted(() => {
</button> </button>
</li> </li>
</ul> </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> </div>
</template> </template>
+79 -4
View File
@@ -424,10 +424,7 @@ async def reset_password():
return json_error(INVALID_RESET, 403) return json_error(INVALID_RESET, 403)
user = await db.get(User, user_id) user = await db.get(User, user_id)
user.password_hash = hash_password(password) user.password_hash = hash_password(password)
user.session_epoch = User.session_epoch + 1 unlinked = await _sign_out_elsewhere(db, user, keep_token=None)
unlinked = (await db.execute(delete(DeviceToken).where(DeviceToken.user_id == user_id))).rowcount
await db.commit()
await db.refresh(user)
_sign_in(user) _sign_in(user)
logger.info( logger.info(
"password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address() "password reset email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address()
@@ -435,6 +432,84 @@ async def reset_password():
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
async def _sign_out_elsewhere(db, user: User, keep_token: str | None) -> int:
"""End the account's sessions, and commit. Returns how many devices were unlinked.
Web sessions end because the account's epoch moves on: they are signed cookies
in other browsers, out of reach. Linked devices are deleted, except the one
holding `keep_token`, when the request came from a device. The caller signs
this browser in again under the new epoch, if it should stay signed in.
"""
user.session_epoch = User.session_epoch + 1
unlink = delete(DeviceToken).where(DeviceToken.user_id == user.id)
if keep_token:
unlink = unlink.where(DeviceToken.token_hash != hash_token(keep_token))
unlinked = (await db.execute(unlink)).rowcount
await db.commit()
await db.refresh(user)
return unlinked
def _stay_signed_in(user: User) -> None:
"""Keep the browser making this request signed in after `_sign_out_elsewhere`. A
device has no session to keep, and isn't given one."""
if _session_user_id() is not None:
_sign_in(user)
@bp.post("/password")
@login_required
async def change_password():
"""Change the account's password, and sign it out everywhere else: every other
web session, and every linked device (family idea #5105, practice 4). The
browser that changed it stays signed in.
The current password is required, so a session left open on someone else's
screen can't take the account over. A wrong one counts against the sign-in
budget, the same as a failed sign-in.
"""
data = await request.get_json(silent=True) or {}
current = data.get("current_password") or ""
password = data.get("new_password") or ""
if len(password) < MIN_PASSWORD_LEN:
return json_error(f"password must be at least {MIN_PASSWORD_LEN} characters", 400)
async with session_scope() as db:
user = await db.get(User, g.user_id)
if user is None:
return json_error("authentication required", 401)
wait = _sign_in_block(user.email)
if wait is not None:
return _throttled(wait)
# 403, not 401: the caller IS signed in, and a 401 would read as "you were
# signed out" to anything that acts on it.
if not user.password_hash or not verify_password(current, user.password_hash):
_sign_in_failed(user.email)
logger.warning("password change refused (wrong password) email=%s from=%s", user.email, client_address())
return json_error("your current password isn't right", 403)
user.password_hash = hash_password(password)
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
_stay_signed_in(user)
logger.info("password changed email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
return jsonify({"ok": True, "devices_unlinked": unlinked})
@bp.post("/sign-out-elsewhere")
@login_required
async def sign_out_elsewhere():
"""Sign the account out of every other browser and unlink every device, keeping
this one signed in (family idea #5105, practice 4). For a session or a device
the person doesn't recognise, or one on a machine they no longer have."""
async with session_scope() as db:
user = await db.get(User, g.user_id)
if user is None:
return json_error("authentication required", 401)
unlinked = await _sign_out_elsewhere(db, user, keep_token=_bearer_token())
_stay_signed_in(user)
logger.info("signed out elsewhere email=%s devices_unlinked=%s from=%s", user.email, unlinked, client_address())
return jsonify({"ok": True, "devices_unlinked": unlinked})
# --- Device (bearer) tokens for native clients — M8 sync hub --- # --- Device (bearer) tokens for native clients — M8 sync hub ---
+86
View File
@@ -1200,6 +1200,92 @@ async def test_a_short_password_leaves_the_reset_link_usable(app_client, db):
assert ok.status_code == 200 assert ok.status_code == 200
# --- Changing a password, and signing out everywhere else (#5105, practice 4) ---
async def _elsewhere(app_client):
"""The owner signed in on `app_client`, a second browser of theirs, and a linked
device. Returns the second browser and the device's bearer header."""
await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
other = create_app().test_client()
signed = await other.post("/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD})
assert signed.status_code == 200
device = await app_client.post("/api/auth/devices", json={"name": "Phone"})
return other, {"Authorization": f"Bearer {(await device.get_json())['token']}"}
async def _me(client, headers=None) -> int:
return (await client.get("/api/auth/me", headers=headers or {})).status_code
async def test_changing_the_password_signs_out_everywhere_but_here(app_client, db):
other, bearer = await _elsewhere(app_client)
resp = await app_client.post(
"/api/auth/password", json={"current_password": _PASSWORD, "new_password": "a-brand-new-password"}
)
assert resp.status_code == 200, await resp.get_data(as_text=True)
assert (await resp.get_json())["devices_unlinked"] == 1
# This browser stays signed in; the other one and the device are out.
assert await _me(app_client) == 200
assert await _me(other) == 401
assert await _me(create_app().test_client(), bearer) == 401
fresh = create_app().test_client()
old = await fresh.post("/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD})
assert old.status_code == 401
new = await fresh.post("/api/auth/login", json={"email": "owner@example.test", "password": "a-brand-new-password"})
assert new.status_code == 200
async def test_a_wrong_current_password_changes_nothing(app_client, db):
other, bearer = await _elsewhere(app_client)
wrong = await app_client.post(
"/api/auth/password", json={"current_password": "not-the-password", "new_password": "a-brand-new-password"}
)
# 403, not 401: this browser is still signed in, and must not read as signed out.
assert wrong.status_code == 403
short = await app_client.post("/api/auth/password", json={"current_password": _PASSWORD, "new_password": "short"})
assert short.status_code == 400
assert await _me(app_client) == 200
assert await _me(other) == 200
assert await _me(create_app().test_client(), bearer) == 200
signed = await create_app().test_client().post(
"/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD}
)
assert signed.status_code == 200
async def test_sign_out_elsewhere_keeps_this_browser_and_the_password(app_client, db):
other, bearer = await _elsewhere(app_client)
resp = await app_client.post("/api/auth/sign-out-elsewhere")
assert resp.status_code == 200
assert (await resp.get_json())["devices_unlinked"] == 1
assert await _me(app_client) == 200
assert await _me(other) == 401
assert await _me(create_app().test_client(), bearer) == 401
signed = await create_app().test_client().post(
"/api/auth/login", json={"email": "owner@example.test", "password": _PASSWORD}
)
assert signed.status_code == 200
async def test_sign_out_elsewhere_from_a_device_keeps_that_device(app_client, db):
other, bearer = await _elsewhere(app_client)
second = await app_client.post("/api/auth/devices", json={"name": "Laptop"})
laptop = {"Authorization": f"Bearer {(await second.get_json())['token']}"}
resp = await create_app().test_client().post("/api/auth/sign-out-elsewhere", headers=bearer)
assert resp.status_code == 200
assert (await resp.get_json())["devices_unlinked"] == 1
assert await _me(create_app().test_client(), bearer) == 200
assert await _me(create_app().test_client(), laptop) == 401
assert await _me(app_client) == 401
assert await _me(other) == 401
async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_client, db): async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_client, db):
"""A session-cookie caller holds no device token, so "revoke the one I'm using" """A session-cookie caller holds no device token, so "revoke the one I'm using"
has nothing to name. Moved here from the unit lane when the session check began has nothing to name. Moved here from the unit lane when the session check began