Files
inkwell/frontend/src/router/index.ts
T
bvandeusenandClaude Opus 5.5 bb591871a4 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>
2026-10-08 10:22:16 -04:00

146 lines
5.9 KiB
TypeScript

import { createRouter, createWebHistory } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { isDesktop, logEvent } from "../desktop/bridge";
// One-time boot diagnostic: the first navigation is where config + session resolve,
// so it's the moment that tells us whether the app got past its startup gate.
let bootLogged = false;
const router = createRouter({
history: createWebHistory(),
routes: [
{
// Persistent authed shell (sidebar + top bar + search); children render in it.
path: "/",
component: () => import("../components/AppShell.vue"),
meta: { requiresAuth: true },
children: [
{ path: "", name: "board", component: () => import("../views/BoardView.vue") },
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
],
},
{
// The quick-capture window (#1899). Its own route because it is its own
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
// hotkey in a browser tab and nothing to summon it.
path: "/capture",
name: "capture",
component: () => import("../views/CaptureView.vue"),
meta: { title: "Quick capture", requiresAuth: true, requiresDesktop: true },
},
{
path: "/settings",
name: "settings",
component: () => import("../views/SettingsView.vue"),
meta: { title: "Settings", requiresAuth: true, requiresAdmin: true },
},
{
// Desktop only: connect this app to a server. Meaningless in the web build,
// which IS a server's UI — there's nothing for it to link to.
path: "/sync",
name: "sync",
component: () => import("../views/SyncView.vue"),
meta: { title: "Sync", requiresAuth: true, requiresDesktop: true },
},
{
// 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
// empty and issuing a token rejects — its server relationship lives at /sync.
// Guarded in the router, not just hidden in the shell, so a typed URL or a
// restored history entry can't land on a dead end either.
path: "/account",
name: "account",
component: () => import("../views/AccountView.vue"),
meta: { title: "Account", requiresAuth: true, requiresServer: true },
},
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
meta: { title: "Sign in", guestOnly: true },
},
{
path: "/register",
name: "register",
component: () => import("../views/RegisterView.vue"),
meta: { title: "Create account", guestOnly: true },
},
{
// Ask for a reset link by email (#5266). Only linked from sign-in when the
// server can send mail; reached otherwise, the server says it can't.
path: "/forgot-password",
name: "forgot-password",
component: () => import("../views/ForgotPasswordView.vue"),
meta: { title: "Forgot password", guestOnly: true },
},
{
// Where a password reset link lands, whether an admin made it (#5173) or it was
// emailed (#5266). Not guest-only: the
// link signs in whoever uses it as the account it was made for, whoever was
// signed in on this browser before.
path: "/reset-password",
name: "reset-password",
component: () => import("../views/ResetPasswordView.vue"),
meta: { title: "Reset password" },
},
],
});
router.beforeEach(async (to) => {
const session = useSessionStore();
const config = useConfigStore();
await config.load();
if (!session.loaded) {
await session.fetchMe();
}
if (!bootLogged) {
bootLogged = true;
logEvent(
"info",
`first route: config(site=${config.siteName}) session(user=${session.user?.email ?? "none"}) -> ${String(to.name ?? to.path)}`,
);
}
if (to.meta.requiresAuth && !session.user) {
return { name: "login", query: to.fullPath !== "/" ? { redirect: to.fullPath } : undefined };
}
if (to.meta.requiresAdmin && !session.user?.is_admin) {
return { name: "board" };
}
if (to.meta.requiresDesktop && !isDesktop()) {
return { name: "board" };
}
// The capture window is opened at `index.html?capture=1` rather than at
// `/capture`, because the bundled assets are served as files and a path with no
// file behind it 404s in the production build — it only routes under the dev
// server. A query string survives that, and this is where it becomes a route.
if (to.query.capture === "1" && to.name !== "capture") {
return { name: "capture" };
}
// Deliberately NOT applied to /login and /register: bouncing those on desktop
// would loop against the requiresAuth guard above the moment a session is
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
// is web-only), and a fresh launch always resolves the local user.
if (to.meta.requiresServer && isDesktop()) {
return { name: "board" };
}
// An invite link opens the form while registration is closed; the server decides
// whether the invite holds.
if (to.name === "register" && !config.allowRegistration && !to.query.invite) {
return { name: "login" };
}
if (to.meta.guestOnly && session.user) {
return { name: "board" };
}
return true;
});
export default router;