feat: production-harden app and finalize merge-ready UX/theme baseline

- complete tokenized theme preset system and admin style guide visibility
- refine dashboard layout/scroll behavior and shared async/request UI states
- add user nav-visibility settings across API, migration, and frontend stores
- harden security headers/CSP handling and related test coverage
- enforce CI repository hygiene + frontend contract/theme/build quality gates
- update docs/env references and make migration smoke test head-aware
This commit is contained in:
Justin Visser 2026-02-19 15:43:20 +01:00
parent ab1d29b203
commit ae2ca8f149
66 changed files with 5655 additions and 853 deletions

View file

@ -1,21 +1,83 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.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 AppModal from "@/components/ui/AppModal.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import {
changePassword,
fetchSettings,
updateSettings,
type UserSettings,
updateSettings,
} from "@/features/settings";
import { ApiRequestError } from "@/lib/api/errors";
import { useAuthStore } from "@/stores/auth";
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
interface NavPageOption {
id: string;
label: string;
description: string;
required: boolean;
adminOnly?: boolean;
}
const NAV_PAGE_OPTIONS: NavPageOption[] = [
{
id: "dashboard",
label: "Dashboard",
description: "Overview and latest publication updates.",
required: true,
},
{
id: "scholars",
label: "Scholars",
description: "Tracked scholar profiles and profile management.",
required: true,
},
{
id: "publications",
label: "Publications",
description: "Review and search discovered publication records.",
required: false,
},
{
id: "settings",
label: "Settings",
description: "Configuration and account controls.",
required: true,
},
{
id: "style-guide",
label: "Style Guide",
description: "Admin-only visual reference for theme and component tokens.",
required: false,
adminOnly: true,
},
{
id: "runs",
label: "Runs",
description: "Admin-only diagnostics and queue operations.",
required: false,
adminOnly: true,
},
{
id: "users",
label: "Users",
description: "Admin-only user management.",
required: false,
adminOnly: true,
},
];
const auth = useAuthStore();
const userSettings = useUserSettingsStore();
const loading = ref(true);
const saving = ref(false);
@ -24,6 +86,7 @@ const updatingPassword = ref(false);
const autoRunEnabled = ref(false);
const runIntervalMinutes = ref("60");
const requestDelaySeconds = ref("2");
const navVisiblePages = ref<string[]>([]);
const currentPassword = ref("");
const newPassword = ref("");
@ -34,21 +97,58 @@ const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const showIngestionModal = ref(false);
const showPasswordModal = ref(false);
const showNavigationModal = ref(false);
const MIN_CHECK_INTERVAL_MINUTES = 15;
const MIN_REQUEST_DELAY_SECONDS = 2;
const visibleNavOptions = computed(() =>
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
);
const visibleNavLabels = computed(() =>
visibleNavOptions.value
.filter((option) => navVisiblePages.value.includes(option.id))
.map((option) => option.label),
);
function hydrateSettings(settings: UserSettings): void {
autoRunEnabled.value = settings.auto_run_enabled;
runIntervalMinutes.value = String(settings.run_interval_minutes);
requestDelaySeconds.value = String(settings.request_delay_seconds);
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
userSettings.applySettings(settings);
}
function parsePositiveInteger(value: string, label: string): number {
function parseBoundedInteger(value: string, label: string, minimum: number): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer.`);
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 isNavPageVisible(pageId: string): boolean {
return navVisiblePages.value.includes(pageId);
}
function onToggleNavPage(page: NavPageOption, event: Event): void {
const input = event.target as HTMLInputElement;
if (page.required && !input.checked) {
return;
}
if (input.checked) {
navVisiblePages.value = normalizeUserNavVisiblePages([...navVisiblePages.value, page.id]);
return;
}
navVisiblePages.value = normalizeUserNavVisiblePages(
navVisiblePages.value.filter((pageId) => pageId !== page.id),
);
}
async function loadSettings(): Promise<void> {
loading.value = true;
errorMessage.value = null;
@ -78,14 +178,24 @@ async function onSaveSettings(): Promise<void> {
try {
const payload: UserSettings = {
auto_run_enabled: autoRunEnabled.value,
run_interval_minutes: parsePositiveInteger(runIntervalMinutes.value, "Run interval"),
request_delay_seconds: parsePositiveInteger(requestDelaySeconds.value, "Request delay"),
run_interval_minutes: parseBoundedInteger(
runIntervalMinutes.value,
"Check interval (minutes)",
MIN_CHECK_INTERVAL_MINUTES,
),
request_delay_seconds: parseBoundedInteger(
requestDelaySeconds.value,
"Delay between requests (seconds)",
MIN_REQUEST_DELAY_SECONDS,
),
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
};
const saved = await updateSettings(payload);
hydrateSettings(saved);
successMessage.value = "Settings updated.";
showIngestionModal.value = false;
showNavigationModal.value = false;
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
@ -142,65 +252,107 @@ onMounted(() => {
</script>
<template>
<AppPage title="Settings" subtitle="Configure ingestion and account controls.">
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Saved</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppPage
title="Settings"
subtitle="Control how often Scholarr checks profiles and how cautiously it sends requests."
>
<RequestStateAlerts
:success-message="successMessage"
success-title="Saved"
:error-message="errorMessage"
:error-request-id="errorRequestId"
error-title="Settings request failed"
@dismiss-success="successMessage = null"
/>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Settings request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="7" />
<template v-else>
<section class="grid gap-4 lg:grid-cols-2">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar ingestion defaults</h2>
<AsyncStateGate :loading="loading" :loading-lines="7" :show-empty="false">
<section class="grid gap-4 xl:grid-cols-3">
<AppCard class="flex h-full flex-col 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 the background checker that looks for new publications on your tracked profiles.
</p>
<dl class="grid gap-2 text-sm text-secondary">
<div class="flex items-center justify-between gap-2">
<dt>Scheduler</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">
<dt>Automatic checks</dt>
<dd class="font-semibold text-ink-primary">
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Run interval</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ runIntervalMinutes }} min</dd>
<dt>Check interval</dt>
<dd class="font-semibold text-ink-primary">Every {{ runIntervalMinutes }} min</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Request delay</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ requestDelaySeconds }} sec</dd>
<dt>Delay between requests</dt>
<dd class="font-semibold text-ink-primary">{{ requestDelaySeconds }} sec</dd>
</div>
</dl>
<AppButton variant="secondary" @click="showIngestionModal = true">
Manage ingestion settings
<AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
Edit checking rules
</AppButton>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Account security</h2>
<p class="text-sm text-secondary">Password changes are handled in a dedicated overlay to reduce clutter.</p>
<AppButton variant="secondary" @click="showPasswordModal = true">Manage password</AppButton>
<AppCard class="flex h-full flex-col 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 from a focused view. This does not affect other users.
</p>
<AppButton variant="secondary" class="mt-auto self-start" @click="showPasswordModal = true">
Change password
</AppButton>
</AppCard>
<AppCard class="flex h-full flex-col gap-4">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Navigation</h2>
<AppHelpHint text="Choose which pages appear in the left sidebar. Dashboard, Scholars, and Settings are always visible." />
</div>
<p class="text-sm text-secondary">
Visible now: {{ visibleNavLabels.length > 0 ? visibleNavLabels.join(", ") : "none" }}.
</p>
<AppButton variant="secondary" class="mt-auto self-start" @click="showNavigationModal = true">
Customize sidebar pages
</AppButton>
</AppCard>
</section>
</template>
</AsyncStateGate>
<AppModal :open="showIngestionModal" title="Ingestion settings" @close="showIngestionModal = false">
<AppModal
:open="showIngestionModal"
title="Automatic Checking Settings"
@close="showIngestionModal = false"
>
<form class="grid gap-3" @submit.prevent="onSaveSettings">
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable scheduler auto-run" />
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable automatic background checks" />
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Run interval (minutes)</span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" min="1" />
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span class="inline-flex items-center gap-1">
Check interval (minutes)
<AppHelpHint text="How often Scholarr starts a background update check." />
</span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="MIN_CHECK_INTERVAL_MINUTES" />
<span class="text-xs text-secondary">Minimum: {{ MIN_CHECK_INTERVAL_MINUTES }} minutes.</span>
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Request delay (seconds)</span>
<AppInput id="request-delay" v-model="requestDelaySeconds" type="number" min="1" />
<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="Pause between profile requests during a check. Higher values are slower but safer." />
</span>
<AppInput
id="request-delay"
v-model="requestDelaySeconds"
type="number"
:min="MIN_REQUEST_DELAY_SECONDS"
/>
<span class="text-xs text-secondary">Minimum: {{ MIN_REQUEST_DELAY_SECONDS }} seconds.</span>
</label>
<div class="mt-2 flex flex-wrap justify-end gap-2">
@ -219,19 +371,19 @@ onMounted(() => {
</form>
</AppModal>
<AppModal :open="showPasswordModal" title="Change password" @close="showPasswordModal = false">
<AppModal :open="showPasswordModal" title="Change Sign-in Password" @close="showPasswordModal = false">
<form class="grid gap-3" @submit.prevent="onChangePassword">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<label class="grid gap-2 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-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<label class="grid gap-2 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-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Confirm new password</span>
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
</label>
@ -251,5 +403,52 @@ onMounted(() => {
</div>
</form>
</AppModal>
<AppModal :open="showNavigationModal" title="Sidebar Page Visibility" @close="showNavigationModal = false">
<form class="grid gap-3" @submit.prevent="onSaveSettings">
<p class="text-sm text-secondary">
Turn optional pages on or off in the sidebar for your account.
</p>
<ul class="grid gap-2">
<li
v-for="option in visibleNavOptions"
:key="option.id"
class="rounded-lg border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
>
<label class="flex items-start gap-3">
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:checked="isNavPageVisible(option.id)"
:disabled="saving || option.required"
@change="onToggleNavPage(option, $event)"
/>
<span class="grid gap-0.5">
<span class="text-sm font-medium text-ink-primary">
{{ option.label }}
<span v-if="option.required" class="ml-1 text-xs text-ink-muted">(required)</span>
</span>
<span class="text-xs text-secondary">{{ option.description }}</span>
</span>
</label>
</li>
</ul>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<AppButton
variant="ghost"
type="button"
:disabled="saving"
@click="showNavigationModal = false"
>
Cancel
</AppButton>
<AppButton type="submit" :disabled="saving">
{{ saving ? "Saving..." : "Save sidebar settings" }}
</AppButton>
</div>
</form>
</AppModal>
</AppPage>
</template>