DRY pass #2, batch 5, F16: the admin lists load through useLoad (#5372)

Accounts, groups, invites and activity each wrote the same first load:
clear the error, try, put the server's reason or a fallback in error,
and stop loading. useLoad (composables/useAction.ts) is that, and its
load() is also the retry.

Kept: ShareDialog and Settings. Their load sets loading back to true on a
reload, which the lists never did, so moving them would change what a
retry shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:46:14 -04:00
co-authored by Claude Opus 5.5
parent 52d53fb78a
commit 6e4c4930cb
5 changed files with 43 additions and 64 deletions
+4 -15
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useSessionStore } from "../stores/session";
import { appLink } from "../router/links";
import OneTimeLink from "./OneTimeLink.vue";
import { useRowAction } from "../composables/useAction";
import { useRowAction, useLoad } 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.
@@ -20,23 +19,13 @@ interface Account {
const session = useSessionStore();
const accounts = ref<Account[]>([]);
const loading = ref(true);
const error = ref("");
const { loading, error, load } = useLoad(async () => {
accounts.value = (await api.get<{ accounts: Account[] }>("/api/accounts")).accounts;
}, "Couldn't load accounts.");
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);
async function load() {
error.value = "";
try {
accounts.value = (await api.get<{ accounts: Account[] }>("/api/accounts")).accounts;
} catch (e) {
error.value = errorMessage(e, "Couldn't load accounts.");
} finally {
loading.value = false;
}
}
async function resetLink(account: Account) {
const self = account.id === session.user?.id;
const warning = self
+6 -15
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { formatShortDateTime } from "../notes/datetime";
import { useLoad } from "../composables/useAction";
// Admin: what has happened to accounts on this instance, newest first (#2939 §5).
// Kept for `audit_retention_days` (Settings → Security).
@@ -45,28 +46,18 @@ const REFUSALS = new Set([
const events = ref<AuditEvent[]>([]);
const more = ref(false);
const loading = ref(true);
const { loading, error, load } = useLoad(async () => {
const res = await page();
events.value = res.events;
more.value = res.more;
}, "Couldn't load activity.");
const loadingMore = ref(false);
const error = ref("");
async function page(before?: string) {
const query = before ? `?before=${encodeURIComponent(before)}` : "";
return api.get<{ events: AuditEvent[]; more: boolean }>(`/api/accounts/activity${query}`);
}
async function load() {
error.value = "";
try {
const res = await page();
events.value = res.events;
more.value = res.more;
} catch (e) {
error.value = errorMessage(e, "Couldn't load activity.");
} finally {
loading.value = false;
}
}
async function loadMore() {
const last = events.value[events.value.length - 1];
if (!last) return;
+9 -20
View File
@@ -5,7 +5,7 @@ import { errorMessage } from "../api/errors";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue";
import { useRowAction } from "../composables/useAction";
import { useRowAction, useLoad } 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,11 +23,16 @@ interface Group {
members: Person[];
}
const groups = ref<Group[]>([]);
const accounts = ref<Person[]>([]);
const loading = ref(true);
const error = ref("");
const { loading, error, load } = useLoad(async () => {
const [g, a] = await Promise.all([
api.get<{ groups: Group[] }>("/api/groups"),
api.get<{ accounts: Person[] }>("/api/accounts"),
]);
groups.value = g.groups;
accounts.value = a.accounts;
}, "Couldn't load groups.");
const name = ref("");
const creating = ref(false);
const { busy, act } = useRowAction();
@@ -43,22 +48,6 @@ function outside(group: Group): Person[] {
const empty = computed(() => !loading.value && !groups.value.length);
async function load() {
error.value = "";
try {
const [g, a] = await Promise.all([
api.get<{ groups: Group[] }>("/api/groups"),
api.get<{ accounts: Person[] }>("/api/accounts"),
]);
groups.value = g.groups;
accounts.value = a.accounts;
} catch (e) {
error.value = errorMessage(e, "Couldn't load groups.");
} finally {
loading.value = false;
}
}
function replace(updated: Group) {
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
}
+4 -14
View File
@@ -6,7 +6,7 @@ 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";
import { toastOnFailure, useLoad } 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,
@@ -30,25 +30,15 @@ const LIFETIMES = [
];
const invites = ref<Invite[]>([]);
const loading = ref(true);
const error = ref("");
const { loading, error, load } = useLoad(async () => {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
}, "Couldn't load invites.");
const email = ref("");
const days = ref(7);
const creating = ref(false);
// The link for the invite just made. Never retrievable again once dismissed.
const freshLink = ref("");
async function load() {
error.value = "";
try {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
} catch (e) {
error.value = errorMessage(e, "Couldn't load invites.");
} finally {
loading.value = false;
}
}
async function create() {
creating.value = true;
error.value = "";
+20
View File
@@ -45,3 +45,23 @@ export function useRowAction() {
}
return { busy, act };
}
/**
* A list's first load: `loading` until it settles, then `error` holding what went
* wrong (the server's reason, else `fallback`) or "". `load` is also the retry.
*/
export function useLoad(work: () => Promise<unknown>, fallback: string) {
const loading = ref(true);
const error = ref("");
async function load(): Promise<void> {
error.value = "";
try {
await work();
} catch (e) {
error.value = errorMessage(e, fallback);
} finally {
loading.value = false;
}
}
return { loading, error, load };
}