after audit

This commit is contained in:
Justin Visser 2026-03-01 18:14:22 +01:00
parent e8e20637e6
commit a5165dc3ba
80 changed files with 8489 additions and 7302 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminIntegritySection from "./AdminIntegritySection.vue";
vi.mock("@/features/admin_dbops", () => ({
getAdminDbIntegrityReport: vi.fn(),
}));
import { getAdminDbIntegrityReport, type AdminDbIntegrityReport } from "@/features/admin_dbops";
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
function buildReport(overrides: Partial<AdminDbIntegrityReport> = {}): AdminDbIntegrityReport {
return {
status: "ok",
warnings: [],
failures: [],
checked_at: "2025-01-15T12:00:00Z",
checks: [
{ name: "orphaned_publications", count: 0, severity: "metric", message: "No orphaned publications found." },
],
...overrides,
};
}
describe("AdminIntegritySection", () => {
beforeEach(() => {
mockedGetReport.mockReset();
});
it("renders the section heading", () => {
const wrapper = mount(AdminIntegritySection);
expect(wrapper.text()).toContain("Integrity Report");
});
it("exposes load function that fetches integrity report", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport());
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("renders check results after loading", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport());
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("orphaned_publications");
});
it("displays warning count when present", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport({ warnings: ["w1", "w2", "w3"] }));
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("3");
});
it("renders status badge", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport({ status: "failed" }));
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("failed");
});
});

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from "vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
getAdminDbIntegrityReport,
type AdminDbIntegrityCheck,
type AdminDbIntegrityReport,
} from "@/features/admin_dbops";
const refreshingIntegrity = ref(false);
const integrityReport = ref<AdminDbIntegrityReport | null>(null);
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
if (status === "ok" || status === "completed" || status === "resolved") return "success";
if (status === "warning" || status === "running" || status === "queued") return "warning";
if (status === "failed") return "danger";
return "info";
}
function checkTone(check: AdminDbIntegrityCheck): "warning" | "danger" | "neutral" | "info" {
if (check.severity === "metric") return "info";
if (check.count <= 0) return "neutral";
return check.severity === "failure" ? "danger" : "warning";
}
async function refreshIntegrity(): Promise<void> {
refreshingIntegrity.value = true;
try {
integrityReport.value = await getAdminDbIntegrityReport();
} finally {
refreshingIntegrity.value = false;
}
}
async function load(): Promise<void> {
await refreshIntegrity();
}
defineExpose({ load });
</script>
<template>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Integrity Report</h2>
<AppHelpHint text="Read-only checks for known corruption patterns and data drift." />
</div>
<AppRefreshButton variant="secondary" :loading="refreshingIntegrity" title="Refresh integrity report" loading-title="Refreshing integrity report" @click="refreshIntegrity" />
</div>
<div v-if="integrityReport" class="flex flex-wrap items-center gap-2 text-xs">
<AppBadge :tone="statusTone(integrityReport.status)">Status: {{ integrityReport.status }}</AppBadge>
<AppBadge tone="warning">Warnings: {{ integrityReport.warnings.length }}</AppBadge>
<AppBadge tone="danger">Failures: {{ integrityReport.failures.length }}</AppBadge>
<span class="text-secondary">Checked: {{ formatTimestamp(integrityReport.checked_at) }}</span>
</div>
<AppTable v-if="integrityReport" label="Integrity checks">
<thead>
<tr>
<th scope="col">Check</th>
<th scope="col">Count</th>
<th scope="col">Severity</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
<tr v-for="check in integrityReport.checks" :key="check.name">
<td>{{ check.name }}</td>
<td>{{ check.count }}</td>
<td><AppBadge :tone="checkTone(check)">{{ check.severity }}</AppBadge></td>
<td>{{ check.message }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
</template>

View file

@ -0,0 +1,104 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
vi.mock("@/features/admin_dbops", () => ({
listAdminPdfQueue: vi.fn(),
requeueAdminPdfLookup: vi.fn(),
requeueAllAdminPdfLookups: vi.fn(),
}));
import { listAdminPdfQueue } from "@/features/admin_dbops";
const mockedListQueue = vi.mocked(listAdminPdfQueue);
function buildQueueItem(overrides: Record<string, unknown> = {}) {
return {
publication_id: 1,
title: "Test Paper",
display_identifier: null,
pdf_url: null,
status: "queued",
attempt_count: 0,
last_failure_reason: null,
last_failure_detail: null,
last_source: "unpaywall",
requested_by_user_id: null,
requested_by_email: null,
queued_at: "2025-01-15T12:00:00Z",
last_attempt_at: null,
resolved_at: null,
updated_at: "2025-01-15T12:00:00Z",
...overrides,
};
}
describe("AdminPdfQueueSection", () => {
beforeEach(() => {
mockedListQueue.mockReset();
});
it("renders the PDF queue heading", () => {
const wrapper = mount(AdminPdfQueueSection);
expect(wrapper.text()).toContain("PDF");
});
it("exposes load function that fetches PDF queue", async () => {
mockedListQueue.mockResolvedValueOnce({
items: [buildQueueItem()],
total_count: 1,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
});
const wrapper = mount(AdminPdfQueueSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(mockedListQueue).toHaveBeenCalled();
});
it("renders queue items after loading", async () => {
mockedListQueue.mockResolvedValueOnce({
items: [buildQueueItem({ title: "My Paper" })],
total_count: 1,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
});
const wrapper = mount(AdminPdfQueueSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("My Paper");
});
it("renders status filter dropdown", () => {
const wrapper = mount(AdminPdfQueueSection);
expect(wrapper.text()).toContain("Status");
});
it("renders queue all button", () => {
const wrapper = mount(AdminPdfQueueSection);
expect(wrapper.text()).toContain("Queue all");
});
it("shows pagination summary after loading", async () => {
mockedListQueue.mockResolvedValueOnce({
items: Array.from({ length: 3 }, (_, i) => buildQueueItem({ publication_id: i + 1 })),
total_count: 3,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
});
const wrapper = mount(AdminPdfQueueSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("3");
});
});

View file

@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
listAdminPdfQueue,
requeueAdminPdfLookup,
requeueAllAdminPdfLookups,
type AdminPdfQueueItem,
} from "@/features/admin_dbops";
import { useRequestState } from "@/composables/useRequestState";
const { clearAlerts, assignError, setSuccess } = useRequestState();
const refreshingPdfQueue = ref(false);
const requeueingPublicationId = ref<number | null>(null);
const requeueingAllPdfs = ref(false);
const pdfQueueItems = ref<AdminPdfQueueItem[]>([]);
const pdfQueueStatusFilter = ref("");
const pdfQueuePage = ref(1);
const pdfQueuePageSize = ref("50");
const pdfQueueTotalCount = ref(0);
const pdfQueueHasNext = ref(false);
const pdfQueueHasPrev = ref(false);
const pdfQueuePageSizeValue = computed(() => {
const parsed = Number(pdfQueuePageSize.value);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(500, Math.trunc(parsed)));
});
const pdfQueueTotalPages = computed(() =>
Math.max(1, Math.ceil(pdfQueueTotalCount.value / Math.max(pdfQueuePageSizeValue.value, 1))),
);
const pdfQueueSummary = computed(() =>
`${pdfQueueTotalCount.value} item${pdfQueueTotalCount.value === 1 ? "" : "s"} total`,
);
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
if (status === "ok" || status === "completed" || status === "resolved") return "success";
if (status === "warning" || status === "running" || status === "queued") return "warning";
if (status === "failed") return "danger";
return "info";
}
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function canRequeuePdf(item: AdminPdfQueueItem): boolean {
return item.status !== "queued" && item.status !== "running";
}
async function refreshPdfQueue(): Promise<void> {
refreshingPdfQueue.value = true;
try {
const status = pdfQueueStatusFilter.value.trim() || null;
const page = await listAdminPdfQueue(pdfQueuePage.value, pdfQueuePageSizeValue.value, status);
pdfQueueItems.value = page.items;
pdfQueueTotalCount.value = page.total_count;
pdfQueueHasNext.value = page.has_next;
pdfQueueHasPrev.value = page.has_prev;
pdfQueuePage.value = page.page;
pdfQueuePageSize.value = String(page.page_size);
} finally {
refreshingPdfQueue.value = false;
}
}
async function onPdfQueueFilterChanged(): Promise<void> {
pdfQueuePage.value = 1;
await refreshPdfQueue();
}
async function onPdfQueuePageSizeChanged(): Promise<void> {
pdfQueuePage.value = 1;
await refreshPdfQueue();
}
async function onPdfQueuePrevPage(): Promise<void> {
if (!pdfQueueHasPrev.value || pdfQueuePage.value <= 1) return;
pdfQueuePage.value = Math.max(pdfQueuePage.value - 1, 1);
await refreshPdfQueue();
}
async function onPdfQueueNextPage(): Promise<void> {
if (!pdfQueueHasNext.value) return;
pdfQueuePage.value += 1;
await refreshPdfQueue();
}
async function onRequeuePdf(item: AdminPdfQueueItem): Promise<void> {
requeueingPublicationId.value = item.publication_id;
clearAlerts();
try {
const result = await requeueAdminPdfLookup(item.publication_id);
setSuccess(result.message);
await refreshPdfQueue();
} catch (error) {
assignError(error, "Unable to requeue PDF lookup.");
} finally {
requeueingPublicationId.value = null;
}
}
async function onRequeueAllPdfs(): Promise<void> {
requeueingAllPdfs.value = true;
clearAlerts();
try {
const result = await requeueAllAdminPdfLookups(5000);
setSuccess(result.message);
await refreshPdfQueue();
} catch (error) {
assignError(error, "Unable to queue missing PDF lookups.");
} finally {
requeueingAllPdfs.value = false;
}
}
async function load(): Promise<void> {
await refreshPdfQueue();
}
defineExpose({ load });
</script>
<template>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">PDF Gathering Queue</h2>
<AppHelpHint text="Live queue and outcome history for PDF acquisition across all publications." />
</div>
<div class="flex items-center gap-2">
<AppSelect v-model="pdfQueueStatusFilter" class="min-w-[12rem] !py-1.5 !text-sm" @change="onPdfQueueFilterChanged">
<option value="">All statuses</option>
<option value="untracked">untracked</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="failed">failed</option>
<option value="resolved">resolved</option>
</AppSelect>
<AppSelect v-model="pdfQueuePageSize" class="min-w-[10rem] !py-1.5 !text-sm" @change="onPdfQueuePageSizeChanged">
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</AppSelect>
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
</AppButton>
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
</div>
</div>
<AppTable label="PDF queue table">
<thead>
<tr>
<th scope="col">Publication</th><th scope="col">Status</th><th scope="col">Attempts</th>
<th scope="col">Failure reason</th><th scope="col">Source</th><th scope="col">Requested by</th>
<th scope="col">Queued</th><th scope="col">Last attempt</th><th scope="col">Resolved</th><th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pdfQueueItems" :key="item.publication_id">
<td>
<div class="grid gap-1">
<span class="font-medium text-ink-primary">{{ item.title }}</span>
<a v-if="item.display_identifier?.url" :href="item.display_identifier.url" target="_blank" rel="noreferrer" class="link-inline text-xs">{{ item.display_identifier.label }}</a>
</div>
</td>
<td><AppBadge :tone="statusTone(item.status)">{{ item.status }}</AppBadge></td>
<td>{{ item.attempt_count }}</td>
<td>{{ item.last_failure_reason || "n/a" }}</td>
<td>{{ item.last_source || "n/a" }}</td>
<td>{{ item.requested_by_email || "n/a" }}</td>
<td>{{ formatTimestamp(item.queued_at) }}</td>
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
<td>{{ formatTimestamp(item.resolved_at) }}</td>
<td>
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
</AppButton>
</td>
</tr>
</tbody>
</AppTable>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-secondary">
<span>{{ pdfQueueSummary }}</span>
<div class="flex items-center gap-2">
<span>Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}</span>
<AppButton variant="ghost" :disabled="!pdfQueueHasPrev" @click="onPdfQueuePrevPage">Prev</AppButton>
<AppButton variant="ghost" :disabled="!pdfQueueHasNext" @click="onPdfQueueNextPage">Next</AppButton>
</div>
</div>
</AppCard>
</template>

View file

@ -0,0 +1,91 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminRepairsSection from "./AdminRepairsSection.vue";
vi.mock("@/features/admin_dbops", () => ({
listAdminDbRepairJobs: vi.fn(),
triggerPublicationLinkRepair: vi.fn(),
triggerPublicationNearDuplicateRepair: vi.fn(),
dropAllPublications: vi.fn(),
}));
import { listAdminDbRepairJobs, dropAllPublications } from "@/features/admin_dbops";
const mockedListJobs = vi.mocked(listAdminDbRepairJobs);
const mockedDrop = vi.mocked(dropAllPublications);
function buildUser(overrides: Record<string, unknown> = {}) {
return {
id: 1,
email: "admin@test.com",
is_admin: true,
is_active: true,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
};
}
describe("AdminRepairsSection", () => {
beforeEach(() => {
mockedListJobs.mockReset();
mockedDrop.mockReset();
});
it("renders the repair sections", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
expect(wrapper.text()).toContain("Publication Link Repair");
expect(wrapper.text()).toContain("Near-Duplicate");
expect(wrapper.text()).toContain("Drop All Publications");
});
it("exposes load function that fetches repair jobs", async () => {
mockedListJobs.mockResolvedValueOnce([]);
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(mockedListJobs).toHaveBeenCalled();
});
it("renders repair job rows after loading", async () => {
mockedListJobs.mockResolvedValueOnce([
{
id: 1,
job_name: "publication_link_repair",
status: "completed",
dry_run: true,
requested_by: "admin@test.com",
scope: {},
summary: { links_in_scope: 100, links_deleted: 5 },
error_text: null,
started_at: "2025-01-15T12:00:00Z",
finished_at: "2025-01-15T12:01:00Z",
created_at: "2025-01-15T12:00:00Z",
updated_at: "2025-01-15T12:01:00Z",
},
]);
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("publication_link_repair");
});
it("has typed confirmation guard for drop all publications", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
const dropButton = wrapper.findAll("button").find((b) => b.text().includes("Drop all publications"));
expect(dropButton?.attributes("disabled")).toBeDefined();
});
it("renders dry-run toggle in repair form", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
expect(wrapper.text()).toContain("Dry-run");
});
it("renders scope mode selector", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
expect(wrapper.text()).toContain("Scope");
});
});

View file

@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
dropAllPublications,
listAdminDbRepairJobs,
triggerPublicationLinkRepair,
triggerPublicationNearDuplicateRepair,
type AdminDbRepairJob,
type NearDuplicateCluster,
type TriggerPublicationLinkRepairResult,
type TriggerPublicationNearDuplicateRepairResult,
} from "@/features/admin_dbops";
import type { AdminUser } from "@/features/admin_users";
import { useRequestState } from "@/composables/useRequestState";
const props = defineProps<{
users: AdminUser[];
}>();
const SCOPE_SINGLE_USER = "single_user";
const SCOPE_ALL_USERS = "all_users";
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
const APPLY_NEAR_DUPLICATES_CONFIRM_TEXT = "MERGE SELECTED DUPLICATES";
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
const { clearAlerts, assignError, setSuccess } = useRequestState();
const refreshingJobs = ref(false);
const runningRepair = ref(false);
const repairJobs = ref<AdminDbRepairJob[]>([]);
const lastRepairResult = ref<TriggerPublicationLinkRepairResult | null>(null);
const repairScopeMode = ref<RepairScopeMode>(SCOPE_SINGLE_USER);
const repairUserId = ref("");
const repairScholarIds = ref("");
const repairRequestedBy = ref("");
const repairDryRun = ref(true);
const repairGcOrphans = ref(false);
const repairConfirmationText = ref("");
const runningNearDuplicateScan = ref(false);
const applyingNearDuplicateRepair = ref(false);
const nearDuplicateRequestedBy = ref("");
const nearDuplicateSimilarityThreshold = ref("0.78");
const nearDuplicateMinSharedTokens = ref("3");
const nearDuplicateMaxYearDelta = ref("1");
const nearDuplicateMaxClusters = ref("25");
const nearDuplicateConfirmationText = ref("");
const nearDuplicateSelectedClusterKeys = ref<Set<string>>(new Set());
const nearDuplicateClusters = ref<NearDuplicateCluster[]>([]);
const lastNearDuplicateResult = ref<TriggerPublicationNearDuplicateRepairResult | null>(null);
const droppingPublications = ref(false);
const dropConfirmationText = ref("");
const dropResult = ref<{ deleted_count: number; message: string } | null>(null);
const dropConfirmationValid = computed(() => dropConfirmationText.value.trim() === DROP_PUBLICATIONS_CONFIRM_TEXT);
const typedConfirmationRequired = computed(
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
);
const nearDuplicateApplyEnabled = computed(() => nearDuplicateSelectedClusterKeys.value.size > 0);
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
if (status === "ok" || status === "completed" || status === "resolved") return "success";
if (status === "warning" || status === "running" || status === "queued") return "warning";
if (status === "failed") return "danger";
return "info";
}
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function parseScholarIds(raw: string): number[] {
const tokens = raw.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
const deduped = new Set<number>();
for (const token of tokens) {
const parsed = Number(token);
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Scholar profile IDs must be positive integers.");
deduped.add(parsed);
}
return [...deduped];
}
function parseRepairUserIdOrThrow(raw: string): number {
const parsed = Number((raw || "").trim());
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Select a valid target user.");
return parsed;
}
function validateTypedConfirmation(): string {
const normalized = repairConfirmationText.value.trim();
if (typedConfirmationRequired.value && normalized !== APPLY_ALL_USERS_CONFIRM_TEXT) {
throw new Error(`Type '${APPLY_ALL_USERS_CONFIRM_TEXT}' to apply repair for all users.`);
}
return normalized;
}
function parseBoundedNumber(raw: string, opts: { minimum: number; maximum: number; fallback: number }): number {
const parsed = Number(raw.trim());
if (!Number.isFinite(parsed)) return opts.fallback;
return Math.max(opts.minimum, Math.min(opts.maximum, parsed));
}
function nearDuplicatePayloadBase() {
return {
similarity_threshold: parseBoundedNumber(nearDuplicateSimilarityThreshold.value, { minimum: 0.5, maximum: 1.0, fallback: 0.78 }),
min_shared_tokens: Math.trunc(parseBoundedNumber(nearDuplicateMinSharedTokens.value, { minimum: 1, maximum: 8, fallback: 3 })),
max_year_delta: Math.trunc(parseBoundedNumber(nearDuplicateMaxYearDelta.value, { minimum: 0, maximum: 5, fallback: 1 })),
max_clusters: Math.trunc(parseBoundedNumber(nearDuplicateMaxClusters.value, { minimum: 1, maximum: 200, fallback: 25 })),
requested_by: nearDuplicateRequestedBy.value.trim() || undefined,
};
}
function checkboxEventChecked(event: Event): boolean {
return event.target instanceof HTMLInputElement ? event.target.checked : false;
}
function summaryCount(job: AdminDbRepairJob, key: string): string {
const value = job.summary[key];
return typeof value === "number" ? String(value) : "n/a";
}
function ensureRepairUserSelected(): void {
if (repairScopeMode.value !== SCOPE_SINGLE_USER || repairUserId.value || props.users.length === 0) return;
repairUserId.value = String(props.users[0].id);
}
async function refreshRepairJobs(): Promise<void> {
refreshingJobs.value = true;
try { repairJobs.value = await listAdminDbRepairJobs(30); } finally { refreshingJobs.value = false; }
}
async function onRunRepair(): Promise<void> {
runningRepair.value = true;
clearAlerts();
try {
const payload = {
scope_mode: repairScopeMode.value,
scholar_profile_ids: parseScholarIds(repairScholarIds.value),
dry_run: repairDryRun.value,
gc_orphan_publications: repairGcOrphans.value,
requested_by: repairRequestedBy.value.trim() || undefined,
confirmation_text: validateTypedConfirmation() || undefined,
user_id: repairScopeMode.value === SCOPE_SINGLE_USER ? parseRepairUserIdOrThrow(repairUserId.value) : undefined,
};
const result = await triggerPublicationLinkRepair(payload);
lastRepairResult.value = result;
setSuccess(`Repair job #${result.job_id} completed (${result.status}).`);
repairConfirmationText.value = "";
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to run publication link repair.");
} finally {
runningRepair.value = false;
}
}
function onToggleNearDuplicateClusterSelection(clusterKey: string, checked: boolean): void {
const next = new Set(nearDuplicateSelectedClusterKeys.value);
if (checked) { next.add(clusterKey); } else { next.delete(clusterKey); }
nearDuplicateSelectedClusterKeys.value = next;
}
async function onRunNearDuplicateScan(): Promise<void> {
runningNearDuplicateScan.value = true;
clearAlerts();
try {
const result = await triggerPublicationNearDuplicateRepair({ dry_run: true, ...nearDuplicatePayloadBase() });
nearDuplicateClusters.value = result.clusters;
nearDuplicateSelectedClusterKeys.value = new Set();
nearDuplicateConfirmationText.value = "";
lastNearDuplicateResult.value = result;
setSuccess(`Near-duplicate scan completed (job #${result.job_id}).`);
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to scan for near-duplicate publications.");
} finally {
runningNearDuplicateScan.value = false;
}
}
async function onApplyNearDuplicateRepair(): Promise<void> {
applyingNearDuplicateRepair.value = true;
clearAlerts();
try {
const selectedKeys = [...nearDuplicateSelectedClusterKeys.value].sort((a, b) => a.localeCompare(b));
if (selectedKeys.length === 0) throw new Error("Select at least one near-duplicate cluster before applying.");
if (nearDuplicateConfirmationText.value.trim() !== APPLY_NEAR_DUPLICATES_CONFIRM_TEXT) {
throw new Error(`Type '${APPLY_NEAR_DUPLICATES_CONFIRM_TEXT}' to confirm merge.`);
}
const result = await triggerPublicationNearDuplicateRepair({
dry_run: false,
selected_cluster_keys: selectedKeys,
confirmation_text: nearDuplicateConfirmationText.value.trim(),
...nearDuplicatePayloadBase(),
});
nearDuplicateClusters.value = result.clusters;
nearDuplicateSelectedClusterKeys.value = new Set();
nearDuplicateConfirmationText.value = "";
lastNearDuplicateResult.value = result;
setSuccess(`Merged selected near-duplicate clusters (job #${result.job_id}).`);
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to apply near-duplicate merge.");
} finally {
applyingNearDuplicateRepair.value = false;
}
}
async function onDropAllPublications(): Promise<void> {
droppingPublications.value = true;
clearAlerts();
dropResult.value = null;
try {
const result = await dropAllPublications(dropConfirmationText.value.trim());
dropResult.value = result;
setSuccess(result.message);
dropConfirmationText.value = "";
} catch (error) {
assignError(error, "Unable to drop publications.");
} finally {
droppingPublications.value = false;
}
}
async function load(): Promise<void> {
await refreshRepairJobs();
ensureRepairUserSelected();
}
defineExpose({ load });
</script>
<template>
<section class="grid gap-4">
<AppCard class="space-y-3">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
<AppHelpHint text="Dry-run first. For all-users apply mode, typed confirmation is required." />
</div>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunRepair">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Scope</span>
<AppSelect v-model="repairScopeMode" @change="ensureRepairUserSelected">
<option :value="SCOPE_SINGLE_USER">Single user</option>
<option :value="SCOPE_ALL_USERS">All users</option>
</AppSelect>
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Target user</span>
<AppSelect v-model="repairUserId" :disabled="repairScopeMode === SCOPE_ALL_USERS || users.length === 0">
<option value="" disabled>Select user</option>
<option v-for="user in users" :key="user.id" :value="String(user.id)">{{ user.email }} (ID {{ user.id }})</option>
</AppSelect>
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Scholar profile IDs (optional)</span>
<AppInput v-model="repairScholarIds" placeholder="e.g. 12,13,14" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Requested by (optional)</span>
<AppInput v-model="repairRequestedBy" placeholder="email/name/ticket id" />
</label>
<div class="flex flex-wrap items-center gap-4 md:col-span-2">
<AppCheckbox id="repair-dry-run" v-model="repairDryRun" label="Dry-run (no writes)" />
<AppCheckbox id="repair-gc-orphans" v-model="repairGcOrphans" label="Delete orphan publications" />
</div>
<label v-if="typedConfirmationRequired" class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm</span>
<AppInput v-model="repairConfirmationText" :placeholder="APPLY_ALL_USERS_CONFIRM_TEXT" />
</label>
<p v-if="repairScopeMode === SCOPE_ALL_USERS" class="text-xs text-secondary md:col-span-2">All-users scope includes every scholar profile across all accounts.</p>
<div class="md:col-span-2">
<AppButton type="submit" :disabled="runningRepair">{{ runningRepair ? "Running..." : "Run repair job" }}</AppButton>
</div>
</form>
<div v-if="lastRepairResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-2 flex flex-wrap items-center gap-2">
<AppBadge :tone="statusTone(lastRepairResult.status)">Job #{{ lastRepairResult.job_id }}</AppBadge>
<span class="text-secondary">Status: {{ lastRepairResult.status }}</span>
</div>
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastRepairResult.summary, null, 2) }}</pre>
</div>
</AppCard>
<AppCard class="space-y-3">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Near-Duplicate Publication Repair</h2>
<AppHelpHint text="Run a dry-run scan first, verify candidate clusters, then merge only selected clusters." />
</div>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunNearDuplicateScan">
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Similarity threshold</span><AppInput v-model="nearDuplicateSimilarityThreshold" placeholder="0.78" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Min shared tokens</span><AppInput v-model="nearDuplicateMinSharedTokens" placeholder="3" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max year delta</span><AppInput v-model="nearDuplicateMaxYearDelta" placeholder="1" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max preview clusters</span><AppInput v-model="nearDuplicateMaxClusters" placeholder="25" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Requested by (optional)</span><AppInput v-model="nearDuplicateRequestedBy" placeholder="email/name/ticket id" /></label>
<div class="md:col-span-2">
<AppButton type="submit" :disabled="runningNearDuplicateScan">{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}</AppButton>
</div>
</form>
<div v-if="nearDuplicateClusters.length > 0" class="space-y-3">
<AppTable label="Near duplicate clusters table">
<thead><tr><th scope="col">Select</th><th scope="col">Cluster</th><th scope="col">Winner</th><th scope="col">Members</th><th scope="col">Similarity</th></tr></thead>
<tbody>
<tr v-for="cluster in nearDuplicateClusters" :key="cluster.cluster_key">
<td><input :id="`near-dup-${cluster.cluster_key}`" class="h-4 w-4 rounded border-stroke-default bg-surface-card" type="checkbox" :checked="nearDuplicateSelectedClusterKeys.has(cluster.cluster_key)" @change="onToggleNearDuplicateClusterSelection(cluster.cluster_key, checkboxEventChecked($event))" /></td>
<td class="font-mono text-xs">{{ cluster.cluster_key }}</td>
<td>#{{ cluster.winner_publication_id }}</td>
<td><div class="grid gap-1"><span v-for="member in cluster.members" :key="member.publication_id" class="text-xs text-secondary">#{{ member.publication_id }} · {{ member.title }}</span></div></td>
<td>{{ cluster.similarity_score.toFixed(2) }}</td>
</tr>
</tbody>
</AppTable>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onApplyNearDuplicateRepair">
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters</span><AppInput v-model="nearDuplicateConfirmationText" :placeholder="APPLY_NEAR_DUPLICATES_CONFIRM_TEXT" /></label>
<div class="md:col-span-2"><AppButton type="submit" :disabled="applyingNearDuplicateRepair || !nearDuplicateApplyEnabled">{{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}</AppButton></div>
</form>
</div>
<div v-if="lastNearDuplicateResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-2 flex flex-wrap items-center gap-2"><AppBadge :tone="statusTone(lastNearDuplicateResult.status)">Job #{{ lastNearDuplicateResult.job_id }}</AppBadge><span class="text-secondary">Status: {{ lastNearDuplicateResult.status }}</span></div>
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}</pre>
</div>
</AppCard>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-ink-primary">Recent Repair Jobs</h2><AppHelpHint text="Audit history and summary counters for each repair job." /></div>
<AppRefreshButton variant="secondary" :loading="refreshingJobs" title="Refresh repair jobs" loading-title="Refreshing repair jobs" @click="refreshRepairJobs" />
</div>
<AppTable label="Repair jobs table">
<thead><tr><th scope="col">Job</th><th scope="col">Status</th><th scope="col">Mode</th><th scope="col">Requested by</th><th scope="col">Created</th><th scope="col">Links in scope</th><th scope="col">Links deleted</th></tr></thead>
<tbody>
<tr v-for="job in repairJobs" :key="job.id">
<td>#{{ job.id }} · {{ job.job_name }}</td>
<td><AppBadge :tone="statusTone(job.status)">{{ job.status }}</AppBadge></td>
<td>{{ job.dry_run ? "dry-run" : "apply" }}</td>
<td>{{ job.requested_by || "n/a" }}</td>
<td>{{ formatTimestamp(job.created_at) }}</td>
<td>{{ summaryCount(job, "links_in_scope") }}</td>
<td>{{ summaryCount(job, "links_deleted") }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
<AppCard class="space-y-3 border-state-danger-border">
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-state-danger-text">Drop All Publications</h2><AppHelpHint text="Permanently delete ALL publications, links, identifiers, and PDF jobs. Scholar baselines will be reset so the next run re-discovers everything." /></div>
<p class="text-sm text-secondary">This action is <strong>irreversible</strong>. It deletes every publication record across all users. The next ingestion run will re-populate all data from scratch.</p>
<form class="grid gap-3" @submit.prevent="onDropAllPublications">
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Type '{{ DROP_PUBLICATIONS_CONFIRM_TEXT }}' to confirm</span><AppInput v-model="dropConfirmationText" :placeholder="DROP_PUBLICATIONS_CONFIRM_TEXT" autocomplete="off" /></label>
<div><AppButton type="submit" variant="danger" :disabled="droppingPublications || !dropConfirmationValid">{{ droppingPublications ? "Dropping..." : "Drop all publications" }}</AppButton></div>
</form>
<div v-if="dropResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-1 flex items-center gap-2"><AppBadge tone="danger">Deleted: {{ dropResult.deleted_count }}</AppBadge></div>
<p class="text-secondary">{{ dropResult.message }}</p>
</div>
</AppCard>
</section>
</template>

View file

@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminUsersSection from "./AdminUsersSection.vue";
vi.mock("@/features/admin_users", () => ({
listAdminUsers: vi.fn(),
createAdminUser: vi.fn(),
setAdminUserActive: vi.fn(),
resetAdminUserPassword: vi.fn(),
}));
import { listAdminUsers, createAdminUser } from "@/features/admin_users";
const mockedListUsers = vi.mocked(listAdminUsers);
const mockedCreateUser = vi.mocked(createAdminUser);
function buildUser(overrides: Record<string, unknown> = {}) {
return {
id: 1,
email: "user@example.com",
is_admin: false,
is_active: true,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
};
}
describe("AdminUsersSection", () => {
beforeEach(() => {
mockedListUsers.mockReset();
mockedCreateUser.mockReset();
});
it("renders the create user form", () => {
const wrapper = mount(AdminUsersSection);
expect(wrapper.text()).toContain("Create user");
});
it("exposes load function that calls listAdminUsers", async () => {
mockedListUsers.mockResolvedValueOnce([buildUser()]);
const wrapper = mount(AdminUsersSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void>; users: unknown[] };
await vm.load();
await flushPromises();
expect(mockedListUsers).toHaveBeenCalled();
expect(vm.users).toHaveLength(1);
});
it("renders user table after loading", async () => {
mockedListUsers.mockResolvedValueOnce([buildUser({ email: "alice@test.com" })]);
const wrapper = mount(AdminUsersSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("alice@test.com");
});
it("propagates error when load fails", async () => {
mockedListUsers.mockRejectedValueOnce(new Error("Network error"));
const wrapper = mount(AdminUsersSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await expect(vm.load()).rejects.toThrow("Network error");
});
it("emits user creation via form submit", async () => {
mockedCreateUser.mockResolvedValueOnce(buildUser({ email: "new@test.com" }));
mockedListUsers.mockResolvedValue([]);
const wrapper = mount(AdminUsersSection);
const inputs = wrapper.findAll("input");
const emailInput = inputs.find((i) => (i.element as HTMLInputElement).type === "email");
const passwordInput = inputs.find((i) => (i.element as HTMLInputElement).type === "password");
if (emailInput && passwordInput) {
await emailInput.setValue("new@test.com");
await passwordInput.setValue("securepassword123");
await wrapper.find("form").trigger("submit");
await flushPromises();
expect(mockedCreateUser).toHaveBeenCalled();
}
});
});

View file

@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
createAdminUser,
listAdminUsers,
resetAdminUserPassword,
setAdminUserActive,
type AdminUser,
} from "@/features/admin_users";
import { useRequestState } from "@/composables/useRequestState";
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
const refreshingUsers = ref(false);
const creating = ref(false);
const togglingUserId = ref<number | null>(null);
const resettingPassword = ref(false);
const users = ref<AdminUser[]>([]);
const email = ref("");
const password = ref("");
const createIsAdmin = ref(false);
const activeUserId = ref<number | null>(null);
const resetPassword = ref("");
const activeUser = computed(() => users.value.find((u) => u.id === activeUserId.value) ?? null);
function userRoleLabel(user: AdminUser): string {
return user.is_admin ? "Admin" : "User";
}
function statusDotClass(user: AdminUser): string {
return user.is_active ? "bg-success-500 ring-success-200" : "bg-ink-muted/70 ring-stroke-default";
}
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function openUserModal(user: AdminUser): void {
activeUserId.value = user.id;
resetPassword.value = "";
}
function closeUserModal(): void {
activeUserId.value = null;
resetPassword.value = "";
}
async function refreshUsers(): Promise<void> {
refreshingUsers.value = true;
try {
users.value = await listAdminUsers();
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
closeUserModal();
}
} finally {
refreshingUsers.value = false;
}
}
async function onCreateUser(): Promise<void> {
creating.value = true;
clearAlerts();
try {
if (!email.value.trim() || !password.value) throw new Error("Email and password are required.");
const created = await createAdminUser({ email: email.value.trim(), password: password.value, is_admin: createIsAdmin.value });
email.value = "";
password.value = "";
createIsAdmin.value = false;
setSuccess(`User created: ${created.email}`);
await refreshUsers();
} catch (error) {
assignError(error, "Unable to create user.");
} finally {
creating.value = false;
}
}
async function onToggleUser(user: AdminUser): Promise<void> {
togglingUserId.value = user.id;
clearAlerts();
try {
const updated = await setAdminUserActive(user.id, !user.is_active);
setSuccess(`${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`);
await refreshUsers();
} catch (error) {
assignError(error, "Unable to update user.");
} finally {
togglingUserId.value = null;
}
}
async function onResetPassword(): Promise<void> {
const user = activeUser.value;
if (!user) return;
resettingPassword.value = true;
clearAlerts();
try {
const candidate = resetPassword.value.trim();
if (candidate.length < 12) throw new Error("New password must be at least 12 characters.");
const result = await resetAdminUserPassword(user.id, candidate);
resetPassword.value = "";
setSuccess(result.message || `Password reset for ${user.email}.`);
} catch (error) {
assignError(error, "Unable to reset password.");
} finally {
resettingPassword.value = false;
}
}
async function load(): Promise<void> {
await refreshUsers();
}
defineExpose({ load, errorMessage, errorRequestId, successMessage, users });
</script>
<template>
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
<AppHelpHint text="Create accounts, toggle active status, and reset passwords." />
</div>
<AppRefreshButton variant="secondary" :loading="refreshingUsers" title="Refresh users" loading-title="Refreshing users" @click="refreshUsers" />
</div>
<form class="grid gap-3 md:grid-cols-3" @submit.prevent="onCreateUser">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Email</span>
<AppInput v-model="email" type="email" autocomplete="off" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Password</span>
<AppInput v-model="password" type="password" autocomplete="new-password" />
</label>
<div class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Role</span>
<div class="flex items-center gap-3">
<AppCheckbox id="admin-create-user-is-admin" v-model="createIsAdmin" label="Grant admin" />
<AppButton type="submit" :disabled="creating">{{ creating ? "Creating..." : "Create user" }}</AppButton>
</div>
</div>
</form>
<AppTable label="Users table">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="align-middle">
<button type="button" class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset" @click="openUserModal(user)">
<span :class="statusDotClass(user)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="user.is_active ? 'Active user' : 'Inactive user'" />
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
</button>
</td>
<td>{{ userRoleLabel(user) }}</td>
<td>{{ user.is_active ? "active" : "inactive" }}</td>
<td class="flex flex-wrap items-center gap-2">
<AppButton variant="secondary" :disabled="togglingUserId === user.id" @click="onToggleUser(user)">
{{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
</AppButton>
<AppButton variant="ghost" @click="openUserModal(user)">Manage</AppButton>
</td>
</tr>
</tbody>
</AppTable>
</AppCard>
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
<div v-if="activeUser" class="grid gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span :class="statusDotClass(activeUser)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'" />
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
</div>
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
<p class="text-xs text-secondary">Last updated: {{ formatTimestamp(activeUser.updated_at) }}</p>
</div>
<div class="grid gap-2">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>New password</span>
<AppInput id="admin-reset-password" v-model="resetPassword" type="password" autocomplete="new-password" placeholder="At least 12 characters" />
</label>
<div class="flex flex-wrap gap-2">
<AppButton :disabled="resettingPassword" @click="onResetPassword">{{ resettingPassword ? "Resetting..." : "Reset password" }}</AppButton>
<AppButton variant="secondary" :disabled="togglingUserId === activeUser.id" @click="onToggleUser(activeUser)">
{{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
</AppButton>
</div>
</div>
</div>
</AppModal>
</template>