- Convert check interval setting from minutes to hours in the UI - Show PDF queue tab to all authenticated users (not admin-only); requeue actions remain admin-only in both backend and frontend - Fix users tab not loading on first visit by removing the AsyncStateGate wrapper that prevented child component refs from being populated before onMounted fired Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
519 lines
20 KiB
Vue
519 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
import AppPage from "@/components/layout/AppPage.vue";
|
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.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 AppTabs, { type AppTabItem } from "@/components/ui/AppTabs.vue";
|
|
import SettingsAdminPanel from "@/features/settings/SettingsAdminPanel.vue";
|
|
import {
|
|
changePassword,
|
|
fetchAdminScholarHttpSettings,
|
|
fetchSettings,
|
|
type AdminScholarHttpSettings,
|
|
type UserSettings,
|
|
type UserSettingsUpdate,
|
|
updateAdminScholarHttpSettings,
|
|
updateSettings,
|
|
} from "@/features/settings";
|
|
import { ApiRequestError } from "@/lib/api/errors";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useRunStatusStore } from "@/stores/run_status";
|
|
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
|
|
|
|
const TAB_CHECKING = "checking";
|
|
const TAB_ACCOUNT = "account";
|
|
const TAB_ADMIN_USERS = "admin-users";
|
|
const TAB_ADMIN_INTEGRITY = "admin-integrity";
|
|
const TAB_ADMIN_REPAIRS = "admin-repairs";
|
|
const TAB_ADMIN_PDF = "admin-pdf";
|
|
const TAB_ADMIN_INTEGRATIONS = "admin-integrations";
|
|
|
|
const auth = useAuthStore();
|
|
const userSettings = useUserSettingsStore();
|
|
const runStatus = useRunStatusStore();
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
|
|
const loading = ref(true);
|
|
const saving = ref(false);
|
|
const savingScholarHttp = ref(false);
|
|
const updatingPassword = ref(false);
|
|
|
|
const autoRunEnabled = ref(false);
|
|
const runIntervalHours = ref("1");
|
|
const requestDelaySeconds = ref("2");
|
|
const navVisiblePages = ref<string[]>([]);
|
|
const openalexApiKey = ref("");
|
|
const crossrefApiToken = ref("");
|
|
const crossrefApiMailto = ref("");
|
|
const scholarHttpUserAgent = ref("");
|
|
const scholarHttpRotateUserAgent = ref(false);
|
|
const scholarHttpAcceptLanguage = ref("en-US,en;q=0.9");
|
|
const scholarHttpCookie = ref("");
|
|
|
|
const currentPassword = ref("");
|
|
const newPassword = ref("");
|
|
const confirmPassword = ref("");
|
|
|
|
const errorMessage = ref<string | null>(null);
|
|
const errorRequestId = ref<string | null>(null);
|
|
const successMessage = ref<string | null>(null);
|
|
|
|
const minCheckIntervalMinutes = ref(15);
|
|
const minRequestDelaySeconds = ref(2);
|
|
const automationAllowed = ref(true);
|
|
const manualRunAllowed = ref(true);
|
|
|
|
const activeTab = ref(TAB_CHECKING);
|
|
|
|
const tabItems = computed<AppTabItem[]>(() => {
|
|
const tabs: AppTabItem[] = [
|
|
{ id: TAB_CHECKING, label: "Checking" },
|
|
{ id: TAB_ACCOUNT, label: "Account" },
|
|
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
|
];
|
|
if (auth.isAdmin) {
|
|
tabs.push(
|
|
{ id: TAB_ADMIN_USERS, label: "Users" },
|
|
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
|
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
|
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
|
|
);
|
|
}
|
|
return tabs;
|
|
});
|
|
|
|
const activeAdminSection = computed<"users" | "integrity" | "repairs" | "pdf" | null>(() => {
|
|
if (activeTab.value === TAB_ADMIN_USERS) {
|
|
return "users";
|
|
}
|
|
if (activeTab.value === TAB_ADMIN_INTEGRITY) {
|
|
return "integrity";
|
|
}
|
|
if (activeTab.value === TAB_ADMIN_REPAIRS) {
|
|
return "repairs";
|
|
}
|
|
if (activeTab.value === TAB_ADMIN_PDF) {
|
|
return "pdf";
|
|
}
|
|
return null;
|
|
});
|
|
|
|
function isKnownTab(tabId: string): boolean {
|
|
return tabItems.value.some((item) => item.id === tabId);
|
|
}
|
|
|
|
async function syncRouteTab(candidate: string): Promise<void> {
|
|
const fallback = tabItems.value[0]?.id || TAB_CHECKING;
|
|
const nextTab = isKnownTab(candidate) ? candidate : fallback;
|
|
activeTab.value = nextTab;
|
|
|
|
const currentQueryTab = typeof route.query.tab === "string" ? route.query.tab : "";
|
|
if (currentQueryTab === nextTab) {
|
|
return;
|
|
}
|
|
|
|
await router.replace({ query: { ...route.query, tab: nextTab } });
|
|
}
|
|
|
|
function hydrateSettings(settings: UserSettings): void {
|
|
const parsedMinRunInterval = Number(settings.policy?.min_run_interval_minutes);
|
|
minCheckIntervalMinutes.value = Number.isFinite(parsedMinRunInterval)
|
|
? Math.max(15, parsedMinRunInterval)
|
|
: 15;
|
|
|
|
const parsedMinRequestDelay = Number(settings.policy?.min_request_delay_seconds);
|
|
minRequestDelaySeconds.value = Number.isFinite(parsedMinRequestDelay)
|
|
? Math.max(2, parsedMinRequestDelay)
|
|
: 2;
|
|
|
|
automationAllowed.value = Boolean(settings.policy?.automation_allowed ?? true);
|
|
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
|
|
|
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
|
runIntervalHours.value = String(Number(settings.run_interval_minutes) / 60);
|
|
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
|
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
|
|
|
openalexApiKey.value = settings.openalex_api_key ?? "";
|
|
crossrefApiToken.value = settings.crossref_api_token ?? "";
|
|
crossrefApiMailto.value = settings.crossref_api_mailto ?? "";
|
|
|
|
userSettings.applySettings(settings);
|
|
runStatus.setSafetyState(settings.safety_state);
|
|
}
|
|
|
|
function hydrateScholarHttpSettings(settings: AdminScholarHttpSettings): void {
|
|
scholarHttpUserAgent.value = settings.user_agent;
|
|
scholarHttpRotateUserAgent.value = Boolean(settings.rotate_user_agent);
|
|
scholarHttpAcceptLanguage.value = settings.accept_language;
|
|
scholarHttpCookie.value = settings.cookie;
|
|
}
|
|
|
|
function parseBoundedInteger(value: string, label: string, minimum: number): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed)) {
|
|
throw new Error(`${label} must be a whole number.`);
|
|
}
|
|
if (parsed < minimum) {
|
|
throw new Error(`${label} must be at least ${minimum}.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseHoursToMinutes(value: string, minMinutes: number): number {
|
|
const hours = Number(value);
|
|
if (!Number.isFinite(hours) || hours <= 0) {
|
|
throw new Error("Check interval must be a positive number of hours.");
|
|
}
|
|
const minutes = Math.round(hours * 60);
|
|
if (minutes < minMinutes) {
|
|
throw new Error(`Check interval must be at least ${minMinutes / 60} hours.`);
|
|
}
|
|
return minutes;
|
|
}
|
|
|
|
async function loadSettings(): Promise<void> {
|
|
loading.value = true;
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
try {
|
|
const settings = await fetchSettings();
|
|
hydrateSettings(settings);
|
|
if (auth.isAdmin) {
|
|
hydrateScholarHttpSettings(await fetchAdminScholarHttpSettings());
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ApiRequestError) {
|
|
errorMessage.value = error.message;
|
|
errorRequestId.value = error.requestId;
|
|
} else {
|
|
errorMessage.value = "Unable to load user settings.";
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function onSaveScholarHttpSettings(): Promise<void> {
|
|
savingScholarHttp.value = true;
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
successMessage.value = null;
|
|
try {
|
|
const updated = await updateAdminScholarHttpSettings({
|
|
user_agent: scholarHttpUserAgent.value.trim(),
|
|
rotate_user_agent: scholarHttpRotateUserAgent.value,
|
|
accept_language: scholarHttpAcceptLanguage.value.trim(),
|
|
cookie: scholarHttpCookie.value.trim(),
|
|
});
|
|
hydrateScholarHttpSettings(updated);
|
|
successMessage.value = "Scholar HTTP profile updated.";
|
|
} catch (error) {
|
|
if (error instanceof ApiRequestError) {
|
|
errorMessage.value = error.message;
|
|
errorRequestId.value = error.requestId;
|
|
} else {
|
|
errorMessage.value = "Unable to save Scholar HTTP profile.";
|
|
}
|
|
} finally {
|
|
savingScholarHttp.value = false;
|
|
}
|
|
}
|
|
|
|
async function onSaveSettings(): Promise<void> {
|
|
saving.value = true;
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
successMessage.value = null;
|
|
|
|
try {
|
|
const payload: UserSettingsUpdate = {
|
|
auto_run_enabled: autoRunEnabled.value,
|
|
run_interval_minutes: parseHoursToMinutes(
|
|
runIntervalHours.value,
|
|
minCheckIntervalMinutes.value,
|
|
),
|
|
request_delay_seconds: parseBoundedInteger(
|
|
requestDelaySeconds.value,
|
|
"Delay between requests (seconds)",
|
|
minRequestDelaySeconds.value,
|
|
),
|
|
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
|
|
openalex_api_key: openalexApiKey.value.trim() || null,
|
|
crossref_api_token: crossrefApiToken.value.trim() || null,
|
|
crossref_api_mailto: crossrefApiMailto.value.trim() || null,
|
|
};
|
|
|
|
const saved = await updateSettings(payload);
|
|
hydrateSettings(saved);
|
|
successMessage.value = "Settings updated.";
|
|
} catch (error) {
|
|
if (error instanceof ApiRequestError) {
|
|
errorMessage.value = error.message;
|
|
errorRequestId.value = error.requestId;
|
|
} else if (error instanceof Error) {
|
|
errorMessage.value = error.message;
|
|
} else {
|
|
errorMessage.value = "Unable to save settings.";
|
|
}
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function onChangePassword(): Promise<void> {
|
|
updatingPassword.value = true;
|
|
errorMessage.value = null;
|
|
errorRequestId.value = null;
|
|
successMessage.value = null;
|
|
|
|
try {
|
|
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
|
|
throw new Error("All password fields are required.");
|
|
}
|
|
|
|
const response = await changePassword({
|
|
current_password: currentPassword.value,
|
|
new_password: newPassword.value,
|
|
confirm_password: confirmPassword.value,
|
|
});
|
|
|
|
currentPassword.value = "";
|
|
newPassword.value = "";
|
|
confirmPassword.value = "";
|
|
successMessage.value = response.message;
|
|
} catch (error) {
|
|
if (error instanceof ApiRequestError) {
|
|
errorMessage.value = error.message;
|
|
errorRequestId.value = error.requestId;
|
|
} else if (error instanceof Error) {
|
|
errorMessage.value = error.message;
|
|
} else {
|
|
errorMessage.value = "Unable to change password.";
|
|
}
|
|
} finally {
|
|
updatingPassword.value = false;
|
|
}
|
|
}
|
|
|
|
async function onSelectTab(tabId: string): Promise<void> {
|
|
await syncRouteTab(tabId);
|
|
}
|
|
|
|
watch(
|
|
() => route.query.tab,
|
|
async (value) => {
|
|
const requestedTab = typeof value === "string" ? value : TAB_CHECKING;
|
|
await syncRouteTab(requestedTab);
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => auth.isAdmin,
|
|
async () => {
|
|
const requestedTab = typeof route.query.tab === "string" ? route.query.tab : TAB_CHECKING;
|
|
await syncRouteTab(requestedTab);
|
|
},
|
|
);
|
|
|
|
onMounted(async () => {
|
|
await loadSettings();
|
|
const requestedTab = typeof route.query.tab === "string" ? route.query.tab : TAB_CHECKING;
|
|
await syncRouteTab(requestedTab);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<AppPage title="Settings" subtitle="Configuration and account controls in one place.">
|
|
<RequestStateAlerts
|
|
:success-message="successMessage"
|
|
success-title="Saved"
|
|
:error-message="errorMessage"
|
|
:error-request-id="errorRequestId"
|
|
error-title="Settings request failed"
|
|
@dismiss-success="successMessage = null"
|
|
/>
|
|
|
|
<AsyncStateGate :loading="loading" :loading-lines="8" :show-empty="false">
|
|
<AppCard class="grid gap-4">
|
|
<AppTabs :model-value="activeTab" :items="tabItems" aria-label="Settings sections" @update:model-value="onSelectTab" />
|
|
|
|
<section v-if="activeTab === TAB_CHECKING" class="grid gap-4">
|
|
<div class="flex items-center gap-1">
|
|
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
|
|
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
|
|
</div>
|
|
<p class="text-sm text-secondary">Configure background checks and request pacing for ingestion.</p>
|
|
|
|
<div class="grid gap-3 md:grid-cols-2 md:items-start">
|
|
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3 md:col-span-2">
|
|
<AppCheckbox
|
|
id="auto-run-enabled"
|
|
v-model="autoRunEnabled"
|
|
:disabled="!automationAllowed"
|
|
label="Enable automatic background checks"
|
|
/>
|
|
<p class="text-xs text-secondary">
|
|
Automatic checks are {{ automationAllowed ? "enabled by policy." : "disabled by server safety policy." }}
|
|
</p>
|
|
</div>
|
|
|
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
|
<span class="inline-flex items-center gap-1">
|
|
Check interval (hours)
|
|
<AppHelpHint text="Minimum is controlled by server policy." />
|
|
</span>
|
|
<AppInput v-model="runIntervalHours" inputmode="decimal" />
|
|
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes / 60 }} hours</span>
|
|
</label>
|
|
|
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
|
<span class="inline-flex items-center gap-1">
|
|
Delay between requests (seconds)
|
|
<AppHelpHint text="Minimum is controlled by safety policy to reduce blocks." />
|
|
</span>
|
|
<AppInput v-model="requestDelaySeconds" inputmode="numeric" />
|
|
<span class="text-xs text-secondary">Minimum: {{ minRequestDelaySeconds }}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
|
|
<span>Automation allowed: {{ automationAllowed ? "yes" : "no" }}</span>
|
|
<span>Manual runs allowed: {{ manualRunAllowed ? "yes" : "no" }}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<AppButton :disabled="saving" @click="onSaveSettings">
|
|
{{ saving ? "Saving..." : "Save checking settings" }}
|
|
</AppButton>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="activeTab === TAB_ACCOUNT" class="grid gap-4">
|
|
<div class="flex items-center gap-1">
|
|
<h2 class="text-lg font-semibold text-ink-primary">Account Access</h2>
|
|
<AppHelpHint text="Manage credentials for your current signed-in account on this instance." />
|
|
</div>
|
|
<p class="text-sm text-secondary">Change your sign-in password directly from this tab.</p>
|
|
<form class="grid gap-3 rounded-lg border border-stroke-default bg-surface-card-muted p-3" @submit.prevent="onChangePassword">
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
|
<span>Current password</span>
|
|
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
|
|
</label>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
|
<span>New password</span>
|
|
<AppInput v-model="newPassword" type="password" autocomplete="new-password" />
|
|
</label>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
|
<span>Confirm new password</span>
|
|
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
|
|
</label>
|
|
<div class="flex gap-2">
|
|
<AppButton type="submit" :disabled="updatingPassword">
|
|
{{ updatingPassword ? "Updating..." : "Update password" }}
|
|
</AppButton>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section v-if="activeTab === TAB_ADMIN_INTEGRATIONS" class="grid gap-4">
|
|
<div class="flex items-center gap-1">
|
|
<h2 class="text-lg font-semibold text-ink-primary">API Integrations</h2>
|
|
<AppHelpHint text="Configure API keys for external services like OpenAlex and Crossref. These are global system settings." />
|
|
</div>
|
|
<p class="text-sm text-secondary">If no keys are provided, the system will gracefully fall back to free unauthenticated tiers where available.</p>
|
|
|
|
<div class="grid gap-3">
|
|
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3">
|
|
<h3 class="text-sm font-semibold text-ink-secondary">OpenAlex</h3>
|
|
<p class="text-xs text-secondary mb-2">
|
|
OpenAlex is a free index of the world's research. Providing a key unlocks a higher rate limit.
|
|
Values returned from the backend might be 'SET' to hide the raw key for security.
|
|
</p>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
|
<span>API Key</span>
|
|
<AppInput v-model="openalexApiKey" placeholder="e.g. iBo3Ye2q322zKYkEyI..." autocomplete="off" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3">
|
|
<h3 class="text-sm font-semibold text-ink-secondary">Crossref</h3>
|
|
<p class="text-xs text-secondary mb-2">
|
|
Crossref metadata search for DOIs. A "mailto" address puts you in their "Polite Pool" for better performance.
|
|
A Plus API token unlocks the fastest tier.
|
|
</p>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
|
<span>API Token (Plus)</span>
|
|
<AppInput v-model="crossrefApiToken" placeholder="Usually empty unless you have an enterprise Plus account" autocomplete="off" />
|
|
</label>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary mt-2">
|
|
<span>Mailto (Polite Pool)</span>
|
|
<AppInput v-model="crossrefApiMailto" placeholder="e.g. admin@yourdomain.com" type="email" autocomplete="off" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3">
|
|
<h3 class="text-sm font-semibold text-ink-secondary">Scholar Request Profile</h3>
|
|
<p class="text-xs text-secondary mb-2">
|
|
Tune Scholar HTTP fingerprint behavior used by live scraper requests. Changes apply to new runs.
|
|
</p>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
|
<span>User-Agent override</span>
|
|
<AppInput
|
|
v-model="scholarHttpUserAgent"
|
|
placeholder="Leave empty to use built-in browser-like user agent"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary mt-2">
|
|
<span>Accept-Language</span>
|
|
<AppInput
|
|
v-model="scholarHttpAcceptLanguage"
|
|
placeholder="e.g. en-US,en;q=0.9"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<label class="grid gap-1 text-sm font-medium text-ink-secondary mt-2">
|
|
<span>Cookie header</span>
|
|
<AppInput
|
|
v-model="scholarHttpCookie"
|
|
placeholder="Optional. Leave empty to disable cookie passthrough"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<div class="mt-2">
|
|
<AppCheckbox
|
|
id="scholar-http-rotate-user-agent"
|
|
v-model="scholarHttpRotateUserAgent"
|
|
label="Rotate user-agent per request"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<AppButton :disabled="savingScholarHttp" @click="onSaveScholarHttpSettings">
|
|
{{ savingScholarHttp ? "Saving..." : "Save Scholar request profile" }}
|
|
</AppButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<AppButton :disabled="saving" @click="onSaveSettings">
|
|
{{ saving ? "Saving..." : "Save integrations" }}
|
|
</AppButton>
|
|
</div>
|
|
</section>
|
|
|
|
<SettingsAdminPanel v-if="activeAdminSection" :section="activeAdminSection" />
|
|
</AppCard>
|
|
</AsyncStateGate>
|
|
|
|
</AppPage>
|
|
</template>
|