Files
inkwell/frontend/src/adapters/local.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

107 lines
5.2 KiB
TypeScript

// Offline desktop implementation of the repository seam: maps each operation to a
// Tauri command backed by the on-device SQLite store (desktop/src-tauri/src/local).
// Selected by index.ts when running inside the desktop shell with no server.
//
// Argument keys are camelCase; Tauri converts them to the Rust commands' snake_case
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
// yet (account auth, device linking) — those reject with a clear message
// rather than silently failing; the board, editor, attachments, capture, filters,
// labels, checklists, reminders, import and export all work fully offline.
import { invoke } from "../desktop/bridge";
import type { Note, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type { DeviceToken, Directory, ImportResult, NoteShare, Repo, ServerSetting } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
export const local: Repo = {
config: {
get: () => invoke<PublicConfig>("config_get"),
},
auth: {
me: () => invoke<User>("auth_me"),
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: {
list: () => Promise.resolve<Device[]>([]),
create: () => Promise.reject<DeviceToken>(new Error(NEEDS_SERVER)),
remove: () => Promise.resolve(),
},
labels: {
list: () => invoke<Label[]>("labels_list"),
create: (name) => invoke<Label>("labels_create", { name }),
rename: (id, name) => invoke<Label>("labels_rename", { id, name }),
setColor: (id, color) => invoke<Label>("labels_set_color", { id, color }),
remove: (id) => invoke<void>("labels_remove", { id }),
merge: (sourceId, into) => invoke<Label>("labels_merge", { sourceId, into }),
},
notes: {
list: (query) => invoke<Note[]>("notes_list", { query }),
get: (id) => invoke<Note>("notes_get", { id }),
create: (input) => invoke<Note>("notes_create", { input }),
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
updateItem: (id, itemId, changes) => invoke<Note>("notes_update_item", { id, itemId, changes }),
// Kept on this device and uploaded by the next sync once linked (#5168). The bytes
// go as the raw IPC body; the name is percent-encoded because a header carries
// only ASCII.
uploadAttachment: async (id, file) =>
invoke<Note>("notes_add_attachment", new Uint8Array(await file.arrayBuffer()), {
headers: {
"x-note-id": id,
"x-filename": encodeURIComponent(file.name),
"x-mime": file.type || "application/octet-stream",
},
}),
deleteAttachment: (id, attId) => invoke<Note>("notes_delete_attachment", { id, attId }),
deletePreview: (id, previewId) => invoke<Note>("notes_delete_preview", { id, previewId }),
// The archive crosses as raw bytes, as an attachment does.
import: async (file) => invoke<ImportResult>("notes_import", new Uint8Array(await file.arrayBuffer())),
exportAll: () => invoke<string>("notes_export"),
reorder: (orderedIds) => invoke<void>("notes_reorder", { orderedIds }),
trash: (id) => invoke<Note>("notes_trash", { id }),
restore: (id) => invoke<Note>("notes_restore", { id }),
deleteForever: (id) => invoke<void>("notes_delete_forever", { id }),
revisions: (id) => invoke<NoteRevision[]>("notes_revisions", { id }),
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
reminders: () => invoke<Note[]>("notes_reminders"),
titles: () => invoke<TitleEntry[]>("notes_titles"),
},
// Sharing is between accounts on a server, so these go to the linked one (#5175).
// Unlinked, each rejects with the core's explanation, which the dialog shows.
settings: {
list: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
save: () => Promise.reject<ServerSetting[]>(new Error(NEEDS_SERVER)),
sendTestEmail: () => Promise.reject<{ to: string }>(new Error(NEEDS_SERVER)),
},
shares: {
directory: () => invoke<Directory>("shares_directory"),
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
share: (noteId, target, permission) =>
invoke<NoteShare[]>("shares_share", {
noteId,
userId: "user_id" in target ? target.user_id : null,
groupId: "group_id" in target ? target.group_id : null,
permission,
}),
unshare: (noteId, shareId) => invoke<NoteShare[]>("shares_unshare", { noteId, shareId }),
},
};