feat: multi-user reading progress & backoffice impersonation

- Scope all reading progress (books, series, stats) by user via
  Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data
- Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN)
- Add X-As-User header support: admin can impersonate any user from backoffice
- UserSwitcher dropdown in nav header (persisted via as_user_id cookie)
- Per-user filter pills on "Currently reading" and "Recently read" dashboard sections
- Inline username editing (UsernameEdit component with optimistic update)
- PATCH /admin/users/:id endpoint to rename a user
- Unassigned read tokens row in users table
- Komga sync now requires a user_id — reading progress attributed to selected user
- Migration 0051: add user_id column to komga_sync_reports
- Nav breakpoints: icons-only from md, labels from xl, hamburger until md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 12:47:58 +01:00
parent 232ecdda41
commit bc796f4ee5
22 changed files with 1326 additions and 152 deletions

View File

@@ -1,11 +1,15 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav";
import { UserSwitcher } from "@/app/components/UserSwitcher";
import { fetchUsers } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr";
@@ -27,6 +31,21 @@ const navItems: NavItem[] = [
export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations();
const cookieStore = await cookies();
const activeUserId = cookieStore.get("as_user_id")?.value || null;
const users = await fetchUsers().catch(() => []);
async function setActiveUserAction(formData: FormData) {
"use server";
const userId = formData.get("user_id") as string;
const store = await cookies();
if (userId) {
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
} else {
store.delete("as_user_id");
}
revalidatePath("/", "layout");
}
return (
<>
@@ -39,7 +58,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
{t("common.backoffice")}
</span>
</div>
@@ -50,16 +69,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
<UserSwitcher
users={users}
activeUserId={activeUserId}
setActiveUserAction={setActiveUserAction}
/>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />