feat(admin): reorganize housekeeping and strengthen shared workflows
CI / check (push) Successful in 1m5s
CI / deploy (push) Successful in 1m4s

This commit is contained in:
Simo committed 2026-09-06 17:17:30 +02:00
1 parent 35f66d879f
commit a070004e7b
35 files changed
+1448 -243

No files matched your search

File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function DevOpsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<a href="/admin/devops/cms-errors" className="btn btn-outline"> <a href="/admin/devops/cms-errors" className="btn btn-outline">
CMS error center {t("cmsErrors")}
</a> </a>
{/* Status cards */} {/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
+6 -28
View File
@@ -11,14 +11,9 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link"; import Link from "@/components/link";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard"; import { requireStaff } from "@/lib/admin/guard";
import { collectNavPermissionSlugs } from "@/lib/admin-nav"; import { loadAdminNavigation } from "@/lib/admin/navigation";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { readCsrfCookieToken } from "@/lib/foundation/security"; import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be // Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
@@ -31,6 +26,7 @@ export default async function AdminLayout({
children: ReactNode; children: ReactNode;
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
const navigation = await loadAdminNavigation();
const csrfToken = await readCsrfCookieToken(); const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) { if (await siteSettings.getBool("force_staff_2fa", false)) {
const u = await db const u = await db
@@ -52,7 +48,7 @@ export default async function AdminLayout({
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full" className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full"
style={{ backgroundColor: "var(--admin-canvas)" }} style={{ backgroundColor: "var(--admin-canvas)" }}
> >
<AdminTopbar staff={staff} /> <AdminTopbar staff={staff} navigation={navigation} />
<section className="admin-page flex-1"> <section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome> <AdminHubChrome>{children}</AdminHubChrome>
</section> </section>
@@ -69,27 +65,8 @@ async function Sidebar({
}) { }) {
const t = await getTranslations("pages.admin.nav"); const t = await getTranslations("pages.admin.nav");
const initial = staff.username.charAt(0).toUpperCase(); const initial = staff.username.charAt(0).toUpperCase();
const { permissions } = await getAdminContext(); const { allowedPermissions, isSuperAdmin, navConfig } =
const isSuperAdmin = permissions.isSuperAdmin; await loadAdminNavigation();
const hasDashboard = canAccess(
permissions,
PERMS.ADMIN_DASHBOARD,
staff.rank,
);
// Super-admins see everything. Everyone else sees items their ACL grants.
// If they can open the admin panel but ACL is incomplete (common after HK→ACL
// migration gaps), still show *.view nav entries so categories do not vanish.
const allowedPermissions = isSuperAdmin
? []
: collectNavPermissionSlugs().filter((slug) => {
if (canAccess(permissions, slug, staff.rank)) return true;
if (!hasDashboard) return false;
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
});
const navConfig = parseAdminNavConfig(
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
);
return ( return (
<aside <aside
@@ -118,6 +95,7 @@ async function Sidebar({
</div> </div>
<AdminSidebarNav <AdminSidebarNav
userId={staff.id}
allowedPermissions={allowedPermissions} allowedPermissions={allowedPermissions}
isSuperAdmin={isSuperAdmin} isSuperAdmin={isSuperAdmin}
navConfig={navConfig} navConfig={navConfig}
+2
View File
@@ -8,6 +8,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { AdminWorkQueue } from "@/components/admin/admin-work-queue";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link"; import Link from "@/components/link";
import { unixNow } from "@/lib/bans"; import { unixNow } from "@/lib/bans";
@@ -126,6 +127,7 @@ export default async function AdminDashboard() {
/> />
</div> </div>
<AdminWorkQueue />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="admin-card p-5"> <section className="admin-card p-5">
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title"> <h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { CatalogGitConfiguration } from "./catalog-git-configuration"; import { CatalogGitConfiguration } from "./catalog-git-configuration";
@@ -25,7 +26,10 @@ export function CatalogExportClient({
const [status, setStatus] = useState<ExportStatus | null>(null); const [status, setStatus] = useState<ExportStatus | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try { try {
const response = await adminFetch("/api/admin/catalog-export", { const response = await adminFetch("/api/admin/catalog-export", {
cache: "no-store", cache: "no-store",
@@ -35,15 +39,11 @@ export function CatalogExportClient({
setError(""); setError("");
} catch { } catch {
setError("Unable to load export status"); setError("Unable to load export status");
} finally {
refreshing.current = false;
} }
}, []); }, []);
useEffect(() => { useVisiblePolling(refresh, 15000);
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15_000);
return () => clearInterval(timer);
}, [refresh]);
async function retry() { async function retry() {
setBusy(true); setBusy(true);
try { try {
+6 -4
View File
@@ -27,8 +27,10 @@ export default async function UserShowPage({
const t = await getTranslations("pages.admin.users.detail"); const t = await getTranslations("pages.admin.users.detail");
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank); const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const data = await loadUserById(id); const data = await loadUserById(id);
const sanctions = await loadUserSanctions(data.user.id); const [sanctions, watched] = await Promise.all([
const watched = await getWatchedUserIds(session.user.id); loadUserSanctions(data.user.id),
getWatchedUserIds(session.user.id),
]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -54,7 +56,7 @@ export default async function UserShowPage({
} }
> >
<Tabs defaultValue="info" className="space-y-4 min-w-0"> <Tabs defaultValue="info" className="space-y-4 min-w-0">
<TabsList> <TabsList className="h-auto flex-wrap justify-start">
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger> <TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
<TabsTrigger value="badges"> <TabsTrigger value="badges">
{t("tabBadges", { count: data.user.badges.length })} {t("tabBadges", { count: data.user.badges.length })}
@@ -75,7 +77,7 @@ export default async function UserShowPage({
<TabsContent value="badges"> <TabsContent value="badges">
<div className="rounded-lg border bg-card p-4"> <div className="rounded-lg border bg-card p-4">
<h3 className="text-sm font-semibold mb-3"> <h3 className="text-sm font-semibold mb-3">
Badges ({data.user.badges.length}) {t("tabBadges", { count: data.user.badges.length })}
</h3> </h3>
<UserBadgesReadonly badges={data.user.badges} /> <UserBadgesReadonly badges={data.user.badges} />
</div> </div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
@@ -26,6 +27,7 @@ export function AdminMobileWrapper({
sidebar: ReactNode; sidebar: ReactNode;
children: ReactNode; children: ReactNode;
}) { }) {
const t = useTranslations("pages.admin.nav");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []); const close = useCallback(() => setOpen(false), []);
const mobile = useMemo(() => ({ close }), [close]); const mobile = useMemo(() => ({ close }), [close]);
@@ -57,6 +59,9 @@ export function AdminMobileWrapper({
<button <button
type="button" type="button"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
aria-label={t(open ? "closeSidebar" : "openSidebar")}
aria-expanded={open}
aria-controls="admin-sidebar-panel"
className="flex items-center justify-center w-9 h-9 rounded-lg className="flex items-center justify-center w-9 h-9 rounded-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]"
style={{ color: "var(--admin-sidebar-text-readable)" }} style={{ color: "var(--admin-sidebar-text-readable)" }}
@@ -67,7 +72,7 @@ export function AdminMobileWrapper({
className="text-sm font-bold" className="text-sm font-bold"
style={{ color: "var(--admin-sidebar-text-readable)" }} style={{ color: "var(--admin-sidebar-text-readable)" }}
> >
Admin Panel {t("housekeeping")}
</span> </span>
</div> </div>
@@ -75,7 +80,7 @@ export function AdminMobileWrapper({
{open && ( {open && (
<button <button
type="button" type="button"
aria-label="Close menu" aria-label={t("closeSidebar")}
className="fixed inset-0 z-40 lg:hidden" className="fixed inset-0 z-40 lg:hidden"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
@@ -84,6 +89,7 @@ export function AdminMobileWrapper({
{/* Sidebar */} {/* Sidebar */}
<div <div
id="admin-sidebar-panel"
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${ className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full" open ? "translate-x-0" : "-translate-x-full"
}`} }`}
+16 -25
View File
@@ -11,16 +11,11 @@ import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { AdminNavLink } from "@/components/admin/admin-nav-link"; import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { import type { AdminNavItem } from "@/lib/admin-nav";
ADMIN_NAV_GROUPS, import type { AdminNavConfig } from "@/lib/admin-nav-config";
type AdminNavItem, import { visibleAdminNavigation } from "@/lib/admin-navigation";
navItemIsAllowed,
} from "@/lib/admin-nav";
import {
type AdminNavConfig,
applyAdminNavConfig,
} from "@/lib/admin-nav-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AdminFavorites } from "./admin-favorites";
const STORAGE_KEY = "admin-nav-collapsed"; const STORAGE_KEY = "admin-nav-collapsed";
@@ -67,10 +62,12 @@ function persistCollapsed(next: Record<string, boolean>) {
} }
export function AdminSidebarNav({ export function AdminSidebarNav({
userId = 0,
allowedPermissions = [], allowedPermissions = [],
isSuperAdmin = false, isSuperAdmin = false,
navConfig = {}, navConfig = {},
}: { }: {
userId?: number;
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */ /** ACL slugs the staff member holds (ignored when isSuperAdmin). */
allowedPermissions?: string[]; allowedPermissions?: string[];
isSuperAdmin?: boolean; isSuperAdmin?: boolean;
@@ -83,24 +80,12 @@ export function AdminSidebarNav({
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const allowed = useMemo( const groups = useMemo(
() => new Set(allowedPermissions), () =>
[allowedPermissions], visibleAdminNavigation({ allowedPermissions, isSuperAdmin, navConfig }),
[allowedPermissions, isSuperAdmin, navConfig],
); );
const groups = useMemo(() => {
const access = {
isSuperAdmin,
has: (slug: string) => allowed.has(slug),
};
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
.map((group) => ({
...group,
items: group.items.filter((item) => navItemIsAllowed(item, access)),
}))
.filter((group) => group.items.length > 0);
}, [allowed, isSuperAdmin, navConfig]);
const search = query.trim().toLocaleLowerCase(); const search = query.trim().toLocaleLowerCase();
const visibleGroups = useMemo( const visibleGroups = useMemo(
() => () =>
@@ -278,6 +263,12 @@ export function AdminSidebarNav({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
{!search && (
<AdminFavorites
userId={userId}
items={groups.flatMap((group) => group.items)}
/>
)}
{visibleGroups.length === 0 && ( {visibleGroups.length === 0 && (
<p <p
role="status" role="status"
+5 -2
View File
@@ -6,11 +6,14 @@ import { SearchDialog } from "@/components/admin/search-dialog";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { findAdminHub } from "@/lib/admin-nav"; import { findAdminHub } from "@/lib/admin-nav";
import type { AdminNavigationOptions } from "@/lib/admin-navigation";
export function AdminTopbar({ export function AdminTopbar({
staff, staff,
navigation,
}: { }: {
staff: { id: number; username: string; rank: number }; staff: { id: number; username: string; rank: number };
navigation: AdminNavigationOptions;
}) { }) {
const tNav = useTranslations("pages.admin.nav"); const tNav = useTranslations("pages.admin.nav");
const tHubs = useTranslations("pages.admin.hubs"); const tHubs = useTranslations("pages.admin.hubs");
@@ -19,7 +22,7 @@ export function AdminTopbar({
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping"); const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
return ( return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card"> <header className="flex items-center justify-between flex-wrap gap-3 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]"> <h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{title} {title}
@@ -30,7 +33,7 @@ export function AdminTopbar({
</span> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SearchDialog /> <SearchDialog navigation={navigation} />
<div className="flex items-center gap-1.5 lg:hidden"> <div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" /> <LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" /> <ThemeSwitcher variant="admin" />
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13 -4
View File
@@ -57,13 +57,17 @@ export function RichText({
defaultValue = "", defaultValue = "",
placeholder, placeholder,
rows = 10, rows = 10,
onChange,
}: { }: {
name: string; name: string;
defaultValue?: string; defaultValue?: string;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
onChange?: () => void;
}) { }) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const changeCallback = useRef(onChange);
changeCallback.current = onChange;
const reactId = useId(); const reactId = useId();
// Stable, querySelector-safe id (useId() contains ':'). // Stable, querySelector-safe id (useId() contains ':').
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
@@ -94,11 +98,16 @@ export function RichText({
on: (ev: string, cb: () => void) => void; on: (ev: string, cb: () => void) => void;
getContent: () => string; getContent: () => string;
}) => { }) => {
const sync = () => { const sync = (notify: boolean) => {
if (textareaRef.current) const field = textareaRef.current;
textareaRef.current.value = editor.getContent(); if (!field) return;
const html = editor.getContent();
const changed = field.value !== html;
field.value = html;
if (notify && changed) changeCallback.current?.();
}; };
editor.on("change keyup undo redo SetContent", sync); editor.on("SetContent", () => sync(false));
editor.on("change keyup undo redo", () => sync(true));
}, },
}) })
.then(() => { .then(() => {
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -1,4 +1,6 @@
"use client";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type SearchInputProps = { type SearchInputProps = {
@@ -15,21 +17,24 @@ export function SearchInput({
placeholder, placeholder,
onClear, onClear,
}: SearchInputProps) { }: SearchInputProps) {
const t = useTranslations("pages.admin.table");
return ( return (
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={placeholder} aria-label={placeholder ?? t("search")}
placeholder={placeholder ?? t("search")}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="pl-9 h-9" style={{ paddingLeft: 36, paddingRight: 40 }}
className="pl-9 pr-10 h-9"
/> />
{value && onClear && ( {value && onClear && (
<button <button
type="button" type="button"
onClick={onClear} onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search" aria-label={t("clearSearch")}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
+35 -15
View File
@@ -1,26 +1,29 @@
"use client"; "use client";
import { useFormatter, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs"; import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({ export function FurnitureJobHistory({
state, state,
}: { }: {
state: ReturnType<typeof useFurnitureJobs>; state: ReturnType<typeof useFurnitureJobs>;
}) { }) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
return ( return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"> <details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer"> <summary className="cursor-pointer">
Import history ·{" "} {t("summary", {
{ count: state.jobs.filter(
state.jobs.filter( (job) => job.state === "queued" || job.state === "running",
(j) => j.state === "queued" || j.state === "running", ).length,
).length })}
}{" "}
active
</summary> </summary>
<div className="max-h-72 space-y-2 overflow-auto py-2"> <div className="max-h-72 space-y-2 overflow-auto py-2">
{state.error && <p role="alert">{state.error}</p>} {state.error && <p role="alert">{state.error}</p>}
{!state.jobs.length && <p>No background imports yet.</p>} {!state.jobs.length && <p>{t("empty")}</p>}
{state.jobs.map((job) => { {state.jobs.map((job) => {
const retryItems = retryableJobItems(job);
const done = job.items.filter((i) => i.state === "done"), const done = job.items.filter((i) => i.state === "done"),
failed = job.items.filter((i) => i.state === "failed"); failed = job.items.filter((i) => i.state === "failed");
return ( return (
@@ -29,14 +32,27 @@ export function FurnitureJobHistory({
className="rounded border border-[var(--admin-border)] p-2" className="rounded border border-[var(--admin-border)] p-2"
> >
<summary className="cursor-pointer"> <summary className="cursor-pointer">
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "} {format.dateTime(new Date(job.createdAt), {
{done.length}/{job.items.length} imported · {failed.length}{" "} dateStyle: "short",
failed timeStyle: "short",
})}{" "}
· {t(job.state)} ·{" "}
{t("result", {
done: done.length,
total: job.items.length,
failed: failed.length,
})}
</summary> </summary>
<progress
className="mt-2 h-2 w-full"
value={done.length + failed.length}
max={Math.max(1, job.items.length)}
aria-label={t("progress")}
/>
<ul className="space-y-1 py-2"> <ul className="space-y-1 py-2">
{job.items.map((item) => ( {job.items.map((item) => (
<li key={item.classname}> <li key={item.classname}>
<strong>{item.classname}</strong> · {item.state} <strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""} {item.itemId ? ` · ID ${item.itemId}` : ""}
{item.error && ( {item.error && (
<p className="text-[var(--admin-warning)]"> <p className="text-[var(--admin-warning)]">
@@ -54,12 +70,12 @@ export function FurnitureJobHistory({
</li> </li>
))} ))}
</ul> </ul>
{failed.length > 0 && job.state !== "running" && ( {retryItems.length > 0 && (
<Button <Button
variant="outline" variant="outline"
disabled={state.busy} disabled={state.busy}
onClick={() => onClick={() =>
void state.submit(failed, { void state.submit(retryItems, {
sourceId: job.sourceId, sourceId: job.sourceId,
mode: job.mode, mode: job.mode,
translate: job.translate, translate: job.translate,
@@ -67,7 +83,11 @@ export function FurnitureJobHistory({
}) })
} }
> >
Retry failed items only {t(
job.state === "interrupted"
? "retryUnfinished"
: "retryFailed",
)}
</Button> </Button>
)} )}
</details> </details>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
export function useFurnitureJobs(onComplete: () => void) { export function useFurnitureJobs(onComplete: () => void) {
@@ -12,7 +13,10 @@ export function useFurnitureJobs(onComplete: () => void) {
callback.current = onComplete; callback.current = onComplete;
const request = useRef<{ payload: string; id: string } | null>(null); const request = useRef<{ payload: string; id: string } | null>(null);
const submitting = useRef(false); const submitting = useRef(false);
const refreshing = useRef(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try { try {
const response = await adminFetch("/api/admin/studio/import-jobs"); const response = await adminFetch("/api/admin/studio/import-jobs");
const data = await response.json(); const data = await response.json();
@@ -36,13 +40,11 @@ export function useFurnitureJobs(onComplete: () => void) {
setError( setError(
error instanceof Error ? error.message : "Cannot load import history", error instanceof Error ? error.message : "Cannot load import history",
); );
} finally {
refreshing.current = false;
} }
}, []); }, []);
useEffect(() => { useVisiblePolling(refresh, 5000);
void refresh();
const timer = setInterval(() => void refresh(), 5000);
return () => clearInterval(timer);
}, [refresh]);
const submit = async ( const submit = async (
items: ImportJobItem[], items: ImportJobItem[],
options: { options: {
+20 -5
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react"; import { useTranslations } from "next-intl";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ActionResult } from "@/lib/safe-action-shared"; import type { ActionResult } from "@/lib/safe-action-shared";
@@ -35,13 +36,19 @@ interface RunOptions {
* run(() => deleteNews(id), { redirectTo: '/admin/news' }) * run(() => deleteNews(id), { redirectTo: '/admin/news' })
*/ */
export function useServerAction(options: UseServerActionOptions = {}) { export function useServerAction(options: UseServerActionOptions = {}) {
const t = useTranslations("pages.admin.actions");
const locked = useRef(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
function run(action: () => Promise<unknown>, opts: RunOptions = {}) { function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
if (locked.current) return;
locked.current = true;
setFieldErrors({});
const { const {
successMessage = "Saved successfully.", successMessage = t("saved"),
errorMessage = "An error occurred.", errorMessage = t("error"),
redirectTo, redirectTo,
onSuccess, onSuccess,
onError, onError,
@@ -61,16 +68,24 @@ export function useServerAction(options: UseServerActionOptions = {}) {
else router.refresh(); else router.refresh();
} }
} else { } else {
toast.error(result.error || errorMessage); setFieldErrors(result.fieldErrors ?? {});
toast.error(result.error || errorMessage, {
description:
Object.values(result.fieldErrors ?? {})
.flat()
.join(" · ") || undefined,
});
onError?.(); onError?.();
} }
} catch (err) { } catch (err) {
console.error("[useServerAction] Unexpected error:", err); console.error("[useServerAction] Unexpected error:", err);
toast.error(errorMessage); toast.error(errorMessage);
onError?.(); onError?.();
} finally {
locked.current = false;
} }
}); });
} }
return { run, isPending }; return { run, isPending, fieldErrors };
} }
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect } from "react";
/** Protect browser close/reload and same-tab link navigation while editing. */
export function useUnsavedChanges(dirty: boolean, message: string) {
useEffect(() => {
if (!dirty) return;
function beforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
event.returnValue = "";
}
function click(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self")
)
return;
const target = new URL(link.href, location.href);
if (
target.origin === location.origin &&
target.pathname === location.pathname &&
target.search === location.search
)
return;
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", click, true);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", click, true);
};
}, [dirty, message]);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
/** Refresh after the prior request settles; pause while the tab is hidden. */
export function useVisiblePolling(
refresh: () => Promise<unknown>,
delay: number,
) {
const callback = useRef(refresh);
callback.current = refresh;
useEffect(() => {
let stopped = false,
running = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function tick() {
if (stopped || running) return;
clearTimeout(timer);
if (document.visibilityState === "hidden") return;
running = true;
try {
await callback.current();
} finally {
running = false;
if (!stopped) timer = setTimeout(tick, delay);
}
}
function visibility() {
if (document.visibilityState === "visible") void tick();
else clearTimeout(timer);
}
void tick();
document.addEventListener("visibilitychange", visibility);
return () => {
stopped = true;
clearTimeout(timer);
document.removeEventListener("visibilitychange", visibility);
};
}, [delay]);
}
+49
View File
@@ -0,0 +1,49 @@
import { createTranslator } from "next-intl";
import { expect, it } from "vitest";
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
import { visibleAdminNavigation } from "@/lib/admin-navigation";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
it.each([
["en", en],
["it", itMessages],
] as const)(
"renders housekeeping navigation and shared controls in %s",
(locale, messages) => {
const errors: unknown[] = [];
const t = createTranslator({
locale,
messages: messages as unknown as Record<string, string>,
onError: (error) => errors.push(error),
});
const translate = t as unknown as (
key: string,
values?: Record<string, string | number>,
) => string;
const keys = new Set([
...ADMIN_NAV_GROUPS.flatMap((g) => [
g.labelKey,
...g.items.map((i) => i.labelKey),
]),
...visibleAdminNavigation({
allowedPermissions: [],
isSuperAdmin: true,
navConfig: {},
}).map((g) => g.labelKey),
]);
for (const key of keys) translate(`pages.admin.nav.${key}`);
for (const area of ["actions", "table", "cmsErrors", "workQueue"] as const)
for (const key of Object.keys(en.pages.admin[area]))
translate(`pages.admin.${area}.${key}`, {
count: 2,
action: "Save",
page: 2,
last: 7,
id: "1",
name: "Name",
groups: 1,
});
expect(errors).toEqual([]);
},
);
+13 -1
View File
@@ -212,8 +212,13 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
icon: Server, icon: Server,
prefixes: ["/admin/devops"], prefixes: ["/admin/devops"],
tabs: [ tabs: [
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] }, {
href: "/admin/devops",
labelKey: "devops",
match: ["/admin/devops"],
},
{ href: "/admin/devops/errors", labelKey: "errors" }, { href: "/admin/devops/errors", labelKey: "errors" },
{ href: "/admin/devops/cms-errors", labelKey: "cmsErrors" },
], ],
}, },
]; ];
@@ -665,6 +670,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
icon: Server, icon: Server,
permission: PERMS.DEVOPS_VIEW, permission: PERMS.DEVOPS_VIEW,
matchPrefixes: ["/admin/devops"], matchPrefixes: ["/admin/devops"],
matchExcludePrefixes: ["/admin/devops/cms-errors"],
},
{
href: "/admin/devops/cms-errors",
labelKey: "cmsErrors",
icon: AlertTriangle,
permission: PERMS.DEVOPS_VIEW,
}, },
{ {
href: "/admin/alerts", href: "/admin/alerts",
+46
View File
@@ -0,0 +1,46 @@
import { expect, it } from "vitest";
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
import { parseFavoritePages, visibleAdminNavigation } from "./admin-navigation";
const access = { isSuperAdmin: true, allowedPermissions: [], navConfig: {} };
it("retains each route and its permission definition once after regrouping", () => {
const original = ADMIN_NAV_GROUPS.flatMap((g) => g.items);
const actual = visibleAdminNavigation(access).flatMap((g) => g.items);
expect(actual).toHaveLength(original.length);
expect(new Set(actual.map((i) => i.href)).size).toBe(actual.length);
for (const item of original) expect(actual).toContain(item);
expect(
visibleAdminNavigation(access)
.find((g) => g.labelKey === "catalogFurniture")
?.items.some((i) => i.href === "/admin/studio"),
).toBe(true);
});
it("respects hidden groups, owner order and ACL in every consumer", () => {
const configured = visibleAdminNavigation({
...access,
navConfig: {
groupOrder: ["monitoring"],
hiddenGroups: ["economy"],
hiddenItems: ["/admin/studio"],
},
});
expect(configured[0].labelKey).toBe("monitoring");
expect(
configured
.flatMap((g) => g.items)
.some((i) => i.href === "/admin/studio" || i.href === "/admin/catalog"),
).toBe(false);
const limited = visibleAdminNavigation({
...access,
isSuperAdmin: false,
}).flatMap((g) => g.items);
expect(limited.every((i) => !i.permission)).toBe(true);
});
it("rejects malformed favorites, external links and duplicates", () => {
expect(parseFavoritePages("{")).toEqual([]);
expect(
parseFavoritePages(
'["/admin/users","https://evil.test","/admin/users",null,"/admin?token=x"]',
),
).toEqual(["/admin/users"]);
});
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
allowed: new Set<string>(),
queried: [] as unknown[],
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (_: unknown, handler: Function) => (request: unknown) =>
handler(request, {
session: { user: { id: 1, rank: 7 } },
permissions: { isSuperAdmin: false },
}),
}));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
}));
vi.mock("@/lib/db", () => {
const tables = Object.fromEntries(
[
"User",
"Rooms",
"Guilds",
"WebsiteArticles",
"WebsiteRareValues",
"WebsiteShopArticles",
].map((name) => [
name,
{
name,
id: `${name}Id`,
username: `${name}Name`,
mail: `${name}Mail`,
slug: `${name}Slug`,
ownerName: `${name}Owner`,
itemId: `${name}Item`,
description: `${name}Description`,
},
]),
);
return {
...tables,
db: {
select() {
let table: unknown;
const query = {
from(value: unknown) {
table = value;
return query;
},
where() {
return query;
},
async limit() {
state.queried.push(table);
return [{ id: 1, title: "Match", subtitle: "Data" }];
},
};
return query;
},
},
};
});
import { GET } from "@/app/api/admin/search/route";
import { PERMS } from "@/lib/permission-slugs";
beforeEach(() => {
state.allowed.clear();
state.queried.length = 0;
});
const request = () =>
({
nextUrl: new URL("https://test.invalid/api/admin/search?q=match"),
}) as Parameters<typeof GET>[0];
it("does not query or expose categories without their view permission", async () => {
const response = await GET(request(), {});
expect((await response.json()).results).toEqual([]);
expect(state.queried).toHaveLength(0);
});
it("searches only the permitted category", async () => {
state.allowed.add(PERMS.NEWS_VIEW);
const response = await GET(request(), {});
const body = await response.json();
expect(body.results).toHaveLength(1);
expect(body.results[0].type).toBe("articles");
expect(state.queried).toHaveLength(1);
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, it } from "vitest";
import { tablePageNumbers } from "./admin-table-state";
it.each([
[1, 1],
[1, 7],
[6, 7],
[7, 7],
[50, 100],
[100, 100],
])("includes the current page %i/%i and stays in bounds", (page, last) => {
const pages = tablePageNumbers(page, last);
expect(pages).toContain(page);
expect(pages.length).toBeLessThanOrEqual(5);
expect(pages.every((p) => p >= 1 && p <= last)).toBe(true);
expect(new Set(pages).size).toBe(pages.length);
});
+6
View File
@@ -0,0 +1,6 @@
/** A centered, bounded window including the current page, also at either end. */
export function tablePageNumbers(page: number, last: number): number[] {
const count = Math.min(5, Math.max(1, last));
const start = Math.max(1, Math.min(page - 2, last - count + 1));
return Array.from({ length: count }, (_, index) => start + index);
}
+32
View File
@@ -0,0 +1,32 @@
import { cache } from "react";
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
import {
ADMIN_NAV_CONFIG_KEY,
parseAdminNavConfig,
} from "@/lib/admin-nav-config";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
export const loadAdminNavigation = cache(async () => {
const [{ session, permissions }, raw] = await Promise.all([
getAdminContext(),
siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
]);
const hasDashboard = canAccess(
permissions,
PERMS.ADMIN_DASHBOARD,
session.user.rank,
);
return {
isSuperAdmin: permissions.isSuperAdmin,
allowedPermissions: permissions.isSuperAdmin
? []
: collectNavPermissionSlugs().filter(
(slug) =>
canAccess(permissions, slug, session.user.rank) ||
(hasDashboard &&
(slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD)),
),
navConfig: parseAdminNavConfig(raw),
};
});
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import type { ImportJob } from "./import-job";
import { retryableJobItems } from "./import-job-retry";
const job = (state: ImportJob["state"]) =>
({
state,
items: ["done", "failed", "pending", "running"].map((state) => ({
state,
classname: state,
})),
}) as ImportJob;
it("only retries failures after a completed job", () =>
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
"failed",
]));
it("recovers all unfinished items from an interrupted job", () =>
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
"failed",
"pending",
"running",
]));
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
expect(retryableJobItems(job(state))).toEqual([]),
);
+10
View File
@@ -0,0 +1,10 @@
import type { ImportJob } from "./import-job";
/** Never requeue an active job or furniture already imported successfully. */
export function retryableJobItems(job: ImportJob): ImportJob["items"] {
if (job.state === "running" || job.state === "queued") return [];
return job.items.filter(
(item) =>
item.state === "failed" ||
(job.state === "interrupted" && item.state !== "done"),
);
}
+4 -4
View File
@@ -81,10 +81,10 @@ describe("staff smoke contract", () => {
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain( expect(readFileSync("src/lib/admin-nav.ts", "utf8")).not.toContain(
"/admin/menu", "/admin/menu",
); );
expect( expect(readFileSync("src/lib/admin-navigation.ts", "utf8")).toContain(
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"), "applyAdminNavConfig",
).toContain("applyAdminNavConfig"); );
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain( expect(readFileSync("src/lib/admin/navigation.ts", "utf8")).toContain(
"ADMIN_NAV_CONFIG_KEY", "ADMIN_NAV_CONFIG_KEY",
); );
}); });
+109 -4
View File
File diff suppressed because it is too large. Load diff
+116 -4
View File
File diff suppressed because it is too large. Load diff