Preference 172. ScrollSentinel watches the end of a cursor-paged list and asks for the next page a screen early. It asks once per cursor, keeps going when a page lands without pushing the end off screen, and stops on a failure until Try again (an observer re-firing on a failure is a request loop). A failed older page now gets its own error beside the retry, instead of replacing the list's load error. Adapted from fabledstash's ScrollSentinel (snippet 3760). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
3.7 KiB
Vue
122 lines
3.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { api } from "../api/client";
|
|
import { errorMessage } from "../api/errors";
|
|
import { formatShortDateTime } from "../notes/datetime";
|
|
import { useLoad } from "../composables/useAction";
|
|
import ScrollSentinel from "./ScrollSentinel.vue";
|
|
|
|
// Admin: what has happened to accounts on this instance, newest first (#2939 §5).
|
|
// Kept for `audit_retention_days` (Settings → Security).
|
|
|
|
interface AuditEvent {
|
|
id: string;
|
|
at: string;
|
|
event: string;
|
|
email: string | null;
|
|
address: string | null;
|
|
detail: string | null;
|
|
}
|
|
|
|
// One phrase per event name in `audit.py`. A new event needs a line here.
|
|
const WORDS: Record<string, string> = {
|
|
sign_in: "Signed in",
|
|
sign_in_failed: "Sign-in failed",
|
|
registered: "Account created",
|
|
registration_refused: "Sign-up refused",
|
|
reset_requested: "Reset email asked for",
|
|
reset_link_made: "Reset link made",
|
|
password_reset: "Password reset",
|
|
reset_refused: "Reset link refused",
|
|
password_changed: "Password changed",
|
|
password_change_refused: "Password change refused",
|
|
signed_out_elsewhere: "Signed out elsewhere",
|
|
device_linked: "Device linked",
|
|
device_unlinked: "Device unlinked",
|
|
invite_created: "Invite made",
|
|
invite_revoked: "Invite revoked",
|
|
};
|
|
|
|
// Shown in red: something was tried and refused.
|
|
const REFUSALS = new Set([
|
|
"sign_in_failed",
|
|
"registration_refused",
|
|
"reset_refused",
|
|
"password_change_refused",
|
|
]);
|
|
|
|
const events = ref<AuditEvent[]>([]);
|
|
const more = ref(false);
|
|
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 moreError = ref("");
|
|
// The next page starts before the oldest event shown; empty once there is none.
|
|
const cursor = computed(() => (more.value ? (events.value[events.value.length - 1]?.at ?? "") : ""));
|
|
|
|
async function page(before?: string) {
|
|
const query = before ? `?before=${encodeURIComponent(before)}` : "";
|
|
return api.get<{ events: AuditEvent[]; more: boolean }>(`/api/accounts/activity${query}`);
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (!cursor.value || loadingMore.value) return;
|
|
loadingMore.value = true;
|
|
moreError.value = "";
|
|
try {
|
|
const res = await page(cursor.value);
|
|
events.value.push(...res.events);
|
|
more.value = res.more;
|
|
} catch (e) {
|
|
moreError.value = errorMessage(e, "Couldn't load older activity.");
|
|
} finally {
|
|
loadingMore.value = false;
|
|
}
|
|
}
|
|
|
|
function where(e: AuditEvent): string {
|
|
return [formatShortDateTime(e.at), e.address, e.detail].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
onMounted(() => {
|
|
void load();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="flex flex-col gap-5">
|
|
<h2 class="section-label">Activity</h2>
|
|
|
|
<p v-if="error" class="form-error">{{ error }}</p>
|
|
|
|
<div v-if="loading" class="list-empty">Loading…</div>
|
|
<p v-else-if="!events.length" class="text-sm text-neutral-400">Nothing yet.</p>
|
|
<ul v-else class="flex flex-col gap-2">
|
|
<li
|
|
v-for="e in events"
|
|
:key="e.id"
|
|
class="rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
|
>
|
|
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
<span :class="REFUSALS.has(e.event) ? 'text-red-600 dark:text-red-400' : ''">
|
|
{{ WORDS[e.event] ?? e.event }}
|
|
</span>
|
|
<span v-if="e.email" class="font-normal text-neutral-500"> · {{ e.email }}</span>
|
|
</p>
|
|
<p class="truncate text-xs text-neutral-400">{{ where(e) }}</p>
|
|
</li>
|
|
</ul>
|
|
|
|
<ScrollSentinel
|
|
v-if="!loading"
|
|
:cursor="cursor"
|
|
:loading="loadingMore"
|
|
:error="moreError"
|
|
@more="loadMore"
|
|
/>
|
|
</section>
|
|
</template>
|