AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user