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:
@@ -45,6 +45,17 @@ export type TokenDto = {
|
||||
scope: string;
|
||||
prefix: string;
|
||||
revoked_at: string | null;
|
||||
user_id?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type UserDto = {
|
||||
id: string;
|
||||
username: string;
|
||||
token_count: number;
|
||||
books_read: number;
|
||||
books_reading: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FolderItem = {
|
||||
@@ -151,6 +162,16 @@ export async function apiFetch<T>(
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const asUserId = cookieStore.get("as_user_id")?.value;
|
||||
if (asUserId) headers.set("X-As-User", asUserId);
|
||||
} catch {
|
||||
// Hors contexte Next.js (tests, etc.)
|
||||
}
|
||||
|
||||
const { next: nextOptions, ...restInit } = init ?? {};
|
||||
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
@@ -268,10 +289,32 @@ export async function listTokens() {
|
||||
return apiFetch<TokenDto[]>("/admin/tokens");
|
||||
}
|
||||
|
||||
export async function createToken(name: string, scope: string) {
|
||||
export async function createToken(name: string, scope: string, userId?: string) {
|
||||
return apiFetch<{ token: string }>("/admin/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scope }),
|
||||
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUsers(): Promise<UserDto[]> {
|
||||
return apiFetch<UserDto[]>("/admin/users");
|
||||
}
|
||||
|
||||
export async function createUser(username: string): Promise<UserDto> {
|
||||
return apiFetch<UserDto>("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, username: string): Promise<void> {
|
||||
return apiFetch<void>(`/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,6 +326,13 @@ export async function deleteToken(id: string) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function updateToken(id: string, userId: string | null) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ user_id: userId || null }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBooks(
|
||||
libraryId?: string,
|
||||
series?: string,
|
||||
@@ -557,6 +607,7 @@ export type CurrentlyReadingItem = {
|
||||
series: string | null;
|
||||
current_page: number;
|
||||
page_count: number;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type RecentlyReadItem = {
|
||||
@@ -564,6 +615,7 @@ export type RecentlyReadItem = {
|
||||
title: string;
|
||||
series: string | null;
|
||||
last_read_at: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type MonthlyReading = {
|
||||
@@ -571,6 +623,12 @@ export type MonthlyReading = {
|
||||
books_read: number;
|
||||
};
|
||||
|
||||
export type UserMonthlyReading = {
|
||||
month: string;
|
||||
username: string;
|
||||
books_read: number;
|
||||
};
|
||||
|
||||
export type JobTimePoint = {
|
||||
label: string;
|
||||
scan: number;
|
||||
@@ -585,6 +643,7 @@ export type StatsResponse = {
|
||||
currently_reading: CurrentlyReadingItem[];
|
||||
recently_read: RecentlyReadItem[];
|
||||
reading_over_time: MonthlyReading[];
|
||||
users_reading_over_time: UserMonthlyReading[];
|
||||
by_format: FormatCount[];
|
||||
by_language: LanguageCount[];
|
||||
by_library: LibraryStatsItem[];
|
||||
@@ -699,11 +758,13 @@ export type KomgaSyncRequest = {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export type KomgaSyncResponse = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
user_id?: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
@@ -717,6 +778,7 @@ export type KomgaSyncResponse = {
|
||||
export type KomgaSyncReportSummary = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
user_id?: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"nav.libraries": "Libraries",
|
||||
"nav.jobs": "Jobs",
|
||||
"nav.tokens": "Tokens",
|
||||
"nav.users": "Users",
|
||||
"nav.settings": "Settings",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Close menu",
|
||||
@@ -96,6 +97,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "No books in progress",
|
||||
"dashboard.noRecentlyRead": "No books read recently",
|
||||
"dashboard.allUsers": "All",
|
||||
|
||||
// Books page
|
||||
"books.title": "Books",
|
||||
@@ -405,6 +407,21 @@ const en: Record<TranslationKey, string> = {
|
||||
"tokens.revoked": "Revoked",
|
||||
"tokens.active": "Active",
|
||||
"tokens.revoke": "Revoke",
|
||||
"tokens.user": "User",
|
||||
"tokens.noUser": "None (admin)",
|
||||
"tokens.apiTokens": "API Tokens",
|
||||
|
||||
// Users page
|
||||
"users.title": "Users",
|
||||
"users.createNew": "Create a user",
|
||||
"users.createDescription": "Create a user account for read access",
|
||||
"users.username": "Username",
|
||||
"users.createButton": "Create",
|
||||
"users.name": "Username",
|
||||
"users.tokenCount": "Tokens",
|
||||
"users.createdAt": "Created",
|
||||
"users.actions": "Actions",
|
||||
"users.noUsers": "No users",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Settings",
|
||||
|
||||
@@ -6,6 +6,7 @@ const fr = {
|
||||
"nav.libraries": "Bibliothèques",
|
||||
"nav.jobs": "Tâches",
|
||||
"nav.tokens": "Jetons",
|
||||
"nav.users": "Utilisateurs",
|
||||
"nav.settings": "Paramètres",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Fermer le menu",
|
||||
@@ -94,6 +95,7 @@ const fr = {
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
||||
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
||||
"dashboard.allUsers": "Tous",
|
||||
|
||||
// Books page
|
||||
"books.title": "Livres",
|
||||
@@ -403,6 +405,21 @@ const fr = {
|
||||
"tokens.revoked": "Révoqué",
|
||||
"tokens.active": "Actif",
|
||||
"tokens.revoke": "Révoquer",
|
||||
"tokens.user": "Utilisateur",
|
||||
"tokens.noUser": "Aucun (admin)",
|
||||
"tokens.apiTokens": "Tokens API",
|
||||
|
||||
// Users page
|
||||
"users.title": "Utilisateurs",
|
||||
"users.createNew": "Créer un utilisateur",
|
||||
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
|
||||
"users.username": "Nom d'utilisateur",
|
||||
"users.createButton": "Créer",
|
||||
"users.name": "Nom d'utilisateur",
|
||||
"users.tokenCount": "Nb de jetons",
|
||||
"users.createdAt": "Créé le",
|
||||
"users.actions": "Actions",
|
||||
"users.noUsers": "Aucun utilisateur",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Paramètres",
|
||||
|
||||
Reference in New Issue
Block a user