AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
10 changed files with 85 additions and 81 deletions
Showing only changes of commit ebbe4a678a - Show all commits
+10 -12
View File
@@ -3,9 +3,9 @@ import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useSessionStore } from "../stores/session";
import { useUiStore } from "../stores/ui";
import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue";
import { useRowAction } from "../composables/useAction";
// Admin: the accounts on this instance, and a password reset link for any of them
// (#5173). There is no mail path, so the admin hands the link over.
@@ -18,12 +18,11 @@ interface Account {
}
const session = useSessionStore();
const ui = useUiStore();
const accounts = ref<Account[]>([]);
const loading = ref(true);
const error = ref("");
const busy = ref("");
const { busy, act } = useRowAction();
// The reset link just made, and whose it is. Never retrievable again once dismissed.
const fresh = ref<{ link: string; email: string } | null>(null);
@@ -44,15 +43,14 @@ async function resetLink(account: Account) {
? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included."
: `Make a password reset link for ${account.email}? Using it signs them out everywhere and unlinks their apps.`;
if (!window.confirm(warning)) return;
busy.value = account.id;
try {
const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {});
fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email };
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't make a reset link."));
} finally {
busy.value = "";
}
await act(
account.id,
async () => {
const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {});
fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email };
},
"Couldn't make a reset link.",
);
}
onMounted(() => {
@@ -4,7 +4,7 @@ import BaseModal from "./BaseModal.vue";
import BaseButton from "./BaseButton.vue";
import { isDesktop, desktop } from "../desktop/bridge";
import { useUiStore } from "../stores/ui";
import { errorMessage } from "../api/errors";
import { useAction } from "../composables/useAction";
// First-run prompt (Linux AppImage only) offering to add Inkwell to the
// applications menu so it behaves like an installed app instead of a loose file.
@@ -15,7 +15,7 @@ const DISMISS_KEY = "desktop.integration.prompted";
const ui = useUiStore();
const show = ref(false);
const working = ref(false);
const { busy: working, act } = useAction();
onMounted(async () => {
if (!isDesktop() || localStorage.getItem(DISMISS_KEY)) return;
@@ -33,17 +33,12 @@ function dismiss() {
}
async function integrate() {
working.value = true;
try {
await act(async () => {
await desktop.integrate();
localStorage.setItem(DISMISS_KEY, "1");
show.value = false;
ui.showToast("Inkwell added to your applications menu.");
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't add to applications."));
} finally {
working.value = false;
}
}, "Couldn't add to applications.");
}
</script>
+4 -9
View File
@@ -2,8 +2,8 @@
import { onMounted, ref } from "vue";
import BaseButton from "./BaseButton.vue";
import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction";
// The applications-menu entry for a Linux AppImage: add it, or take it away again.
// Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build.
@@ -12,7 +12,7 @@ import { useUiStore } from "../stores/ui";
// first-run prompt promised ("undo any time") with no way to reach it.
const ui = useUiStore();
const status = ref<IntegrationStatus | null>(null);
const busy = ref(false);
const { busy, act } = useAction();
onMounted(async () => {
if (!isDesktop()) return;
@@ -24,17 +24,12 @@ onMounted(async () => {
});
async function toggle() {
busy.value = true;
try {
await act(async () => {
status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate();
ui.showToast(
status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.",
);
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't update the menu entry."));
} finally {
busy.value = false;
}
}, "Couldn't update the menu entry.");
}
</script>
+4 -10
View File
@@ -1,27 +1,21 @@
<script setup lang="ts">
import { ref } from "vue";
import Icon from "./Icon.vue";
import { repo } from "../adapters";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import { useAction } from "../composables/useAction";
// Sidebar Export. In a browser the server streams the zip and the browser saves it;
// on the desktop the core builds it from the local store with no server, saves it in
// Downloads, and says exactly where.
const ui = useUiStore();
const busy = ref(false);
const { busy, act } = useAction();
async function run() {
if (busy.value) return;
busy.value = true;
try {
await act(async () => {
const savedTo = await repo.notes.exportAll();
if (savedTo) ui.showToast(`Exported to ${savedTo}`);
} catch (err) {
ui.showToast(errorMessage(err, "Export failed."));
} finally {
busy.value = false;
}
}, "Export failed.");
}
</script>
+2 -14
View File
@@ -2,10 +2,10 @@
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue";
import { useRowAction } from "../composables/useAction";
// Admin: groups of people a note can be shared with in one go (#5177). A group is
// the instance's; anyone can share with it, and a note shared with it reaches whoever
@@ -23,7 +23,6 @@ interface Group {
members: Person[];
}
const ui = useUiStore();
const groups = ref<Group[]>([]);
const accounts = ref<Person[]>([]);
@@ -31,7 +30,7 @@ const loading = ref(true);
const error = ref("");
const name = ref("");
const creating = ref(false);
const busy = ref("");
const { busy, act } = useRowAction();
// Which group's "Add someone" picker holds which account id.
const picks = ref<Record<string, string>>({});
@@ -64,17 +63,6 @@ function replace(updated: Group) {
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
}
async function act(id: string, fn: () => Promise<void>, fallback: string) {
busy.value = id;
try {
await fn();
} catch (e) {
ui.showToast(errorMessage(e, fallback));
} finally {
busy.value = "";
}
}
async function create() {
if (!name.value.trim()) return;
creating.value = true;
+4 -9
View File
@@ -4,7 +4,7 @@ import Icon from "./Icon.vue";
import { useNotesStore } from "../stores/notes";
import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui";
import { errorMessage } from "../api/errors";
import { useAction } from "../composables/useAction";
// Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip,
// or a Google Keep Takeout archive) and import its notes. Additive — never
@@ -13,7 +13,7 @@ const notes = useNotesStore();
const labels = useLabelsStore();
const ui = useUiStore();
const inputRef = ref<HTMLInputElement | null>(null);
const busy = ref(false);
const { busy, act } = useAction();
function pick() {
if (!busy.value) inputRef.value?.click();
@@ -24,18 +24,13 @@ async function onFile(e: Event) {
const file = input.files?.[0];
input.value = ""; // reset so picking the same file again re-fires change
if (!file) return;
busy.value = true;
try {
await act(async () => {
const res = await notes.importNotes(file);
await labels.load(); // surface any labels the import created
const noun = res.imported === 1 ? "note" : "notes";
const tail = res.skipped ? ` (${res.skipped} skipped)` : "";
ui.showToast(`Imported ${res.imported} ${noun}${tail}.`);
} catch (err) {
ui.showToast(errorMessage(err, "Import failed."));
} finally {
busy.value = false;
}
}, "Import failed.");
}
</script>
+3 -6
View File
@@ -2,11 +2,11 @@
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import { appLink } from "../router/links";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import OneTimeLink from "./OneTimeLink.vue";
import { toastOnFailure } from "../composables/useAction";
// Admin: invite one person to register while registration stays closed (#5172).
// The server keeps only a hash of each invite's token, so the link is shown once,
@@ -29,7 +29,6 @@ const LIFETIMES = [
{ days: 30, label: "30 days" },
];
const ui = useUiStore();
const invites = ref<Invite[]>([]);
const loading = ref(true);
@@ -42,7 +41,7 @@ const freshLink = ref("");
async function load() {
error.value = "";
try {
await toastOnFailure(async () => {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
} catch (e) {
error.value = errorMessage(e, "Couldn't load invites.");
@@ -75,9 +74,7 @@ async function revoke(invite: Invite) {
try {
const updated = await api.del<Invite>(`/api/invites/${invite.id}`);
invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i));
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't revoke that invite."));
}
}, "Couldn't revoke that invite.");
}
function who(invite: Invite): string {
+47
View File
@@ -0,0 +1,47 @@
import { ref } from "vue";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
/**
* Run `work`, and say so in a toast if it fails: the server's own reason when it
* gave one, else `fallback`. The one way a button's action reports a failure it
* has no better place to show.
*/
export async function toastOnFailure(work: () => Promise<unknown>, fallback: string): Promise<void> {
try {
await work();
} catch (e) {
useUiStore().showToast(errorMessage(e, fallback));
}
}
/** A single action and whether it is running, for the button that starts it. */
export function useAction() {
const busy = ref(false);
async function act(work: () => Promise<unknown>, fallback: string): Promise<void> {
busy.value = true;
try {
await toastOnFailure(work, fallback);
} finally {
busy.value = false;
}
}
return { busy, act };
}
/**
* The same for a list whose rows each carry an action: `busy` is the id of the row
* whose action is running, or "" when none is, so only that row's buttons wait.
*/
export function useRowAction() {
const busy = ref("");
async function act(id: string, work: () => Promise<unknown>, fallback: string): Promise<void> {
busy.value = id;
try {
await toastOnFailure(work, fallback);
} finally {
busy.value = "";
}
}
return { busy, act };
}
+4 -8
View File
@@ -13,6 +13,7 @@ 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
@@ -96,7 +97,7 @@ const currentPassword = ref("");
const newPassword = ref("");
const changing = ref(false);
const passwordError = ref("");
const signingOut = ref(false);
const { busy: signingOut, act } = useAction();
function unlinkedNote(n: number): string {
if (n === 0) return "";
@@ -124,16 +125,11 @@ async function signOutElsewhere() {
"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 {
await act(async () => {
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;
}
}, "Couldn't sign out everywhere else.");
}
onMounted(() => {
+3 -4
View File
@@ -24,6 +24,7 @@ import {
} from "../desktop/bridge";
import { errorMessage } from "../api/errors";
import { formatDateTime } from "../notes/datetime";
import { toastOnFailure } from "../composables/useAction";
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
// state, not an incomplete setup — the app is local-first and fully usable having
@@ -319,7 +320,7 @@ async function disconnect() {
) {
return;
}
try {
await toastOnFailure(async () => {
const result = await syncBridge.unlink();
status.value = result.status;
linkedAs.value = "";
@@ -331,9 +332,7 @@ async function disconnect() {
? "Disconnected, and this device is signed out on the server."
: "Disconnected. This device now works offline only.",
);
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't disconnect."));
}
}, "Couldn't disconnect.");
}
// A background cycle finished — redraw from it, so this screen is never staler