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,18 +1,20 @@
<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 AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppModal from "@/components/ui/AppModal.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
createAdminUser,
listAdminUsers,
resetAdminUserPassword,
setAdminUserActive,
type AdminUser,
} from "@/features/admin_users";
@ -21,16 +23,21 @@ import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true);
const creating = ref(false);
const togglingUserId = ref<number | null>(null);
const resettingPassword = ref(false);
const activeUserId = ref<number | null>(null);
const users = ref<AdminUser[]>([]);
const email = ref("");
const password = ref("");
const createIsAdmin = ref(false);
const resetPassword = ref("");
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const activeUser = computed(() => users.value.find((item) => item.id === activeUserId.value) ?? null);
function formatDate(value: string): string {
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
@ -39,6 +46,26 @@ function formatDate(value: string): string {
return asDate.toLocaleString();
}
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 openUserModal(user: AdminUser): void {
activeUserId.value = user.id;
resetPassword.value = "";
}
function closeUserModal(): void {
activeUserId.value = null;
resetPassword.value = "";
}
async function loadUsers(): Promise<void> {
loading.value = true;
errorMessage.value = null;
@ -46,6 +73,9 @@ async function loadUsers(): Promise<void> {
try {
users.value = await listAdminUsers();
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
closeUserModal();
}
} catch (error) {
users.value = [];
if (error instanceof ApiRequestError) {
@ -115,6 +145,40 @@ async function onToggleUser(user: AdminUser): Promise<void> {
}
}
async function onResetPassword(): Promise<void> {
const user = activeUser.value;
if (!user) {
return;
}
resettingPassword.value = true;
successMessage.value = null;
errorMessage.value = null;
errorRequestId.value = null;
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);
successMessage.value = result.message || `Password reset for ${user.email}.`;
resetPassword.value = "";
} 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 reset password.";
}
} finally {
resettingPassword.value = false;
}
}
onMounted(() => {
void loadUsers();
});
@ -122,27 +186,27 @@ onMounted(() => {
<template>
<AppPage title="Admin Users" subtitle="Create and manage user access for this instance.">
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Operation complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>User management request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<RequestStateAlerts
:success-message="successMessage"
:error-message="errorMessage"
:error-request-id="errorRequestId"
error-title="User management request failed"
@dismiss-success="successMessage = null"
/>
<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">Create User</h2>
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Create User</h2>
<AppHelpHint text="Create local accounts for this Scholarr instance and optionally grant admin rights." />
</div>
<form class="grid gap-3" @submit.prevent="onCreateUser">
<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>Email</span>
<AppInput id="admin-user-email" v-model="email" type="email" autocomplete="off" />
</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>Password</span>
<AppInput id="admin-user-password" v-model="password" type="password" autocomplete="new-password" />
</label>
@ -156,44 +220,97 @@ onMounted(() => {
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Users</h2>
<AppSkeleton v-if="loading" :lines="5" />
<AppEmptyState
v-else-if="users.length === 0"
title="No users available"
body="Create an account to begin assigning access."
/>
<AppTable v-else label="Admin users table">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Role</th>
<th scope="col">Active</th>
<th scope="col">Updated</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.is_admin ? "Admin" : "User" }}</td>
<td>{{ user.is_active ? "Yes" : "No" }}</td>
<td>{{ formatDate(user.updated_at) }}</td>
<td>
<AppButton
variant="secondary"
:disabled="togglingUserId === user.id"
@click="onToggleUser(user)"
>
{{ user.is_active ? "Deactivate" : "Activate" }}
</AppButton>
</td>
</tr>
</tbody>
</AppTable>
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
<AppHelpHint text="Review account roles and active status. Inactive users cannot sign in." />
</div>
<AsyncStateGate
:loading="loading"
:loading-lines="5"
:empty="users.length === 0"
:show-empty="!errorMessage"
empty-title="No users available"
empty-body="Create an account to begin assigning access."
>
<AppTable label="Admin users table">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<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>
</tr>
</tbody>
</AppTable>
<p class="text-xs text-secondary">Status dot: green is active, gray is inactive. Click an email to manage.</p>
</AsyncStateGate>
</AppCard>
</section>
<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: {{ formatDate(activeUser.updated_at) }}</p>
</div>
<div class="grid gap-2 border-t border-stroke-default pt-3">
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="admin-user-reset-password">
<span>Reset password</span>
<AppInput
id="admin-user-reset-password"
v-model="resetPassword"
type="password"
autocomplete="new-password"
placeholder="Minimum 12 characters"
:disabled="resettingPassword"
/>
</label>
<div class="flex flex-wrap justify-end gap-2">
<AppButton variant="secondary" :disabled="resettingPassword" @click="onResetPassword">
{{ resettingPassword ? "Resetting..." : "Reset password" }}
</AppButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
<AppButton
variant="secondary"
:disabled="togglingUserId === activeUser.id"
@click="onToggleUser(activeUser)"
>
{{ activeUser.is_active ? "Deactivate user" : "Activate user" }}
</AppButton>
<AppButton variant="ghost" :disabled="togglingUserId === activeUser.id || resettingPassword" @click="closeUserModal">
Close
</AppButton>
</div>
</div>
</AppModal>
</AppPage>
</template>