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,10 +1,11 @@
use axum::extract::Extension;
use axum::{extract::{Path, Query, State}, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{books::BookItem, error::ApiError, state::AppState};
use crate::{auth::AuthUser, books::BookItem, error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct SeriesItem {
@@ -70,9 +71,11 @@ pub struct ListSeriesQuery {
)]
pub async fn list_series(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(library_id): Path<Uuid>,
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
@@ -115,6 +118,10 @@ pub async fn list_series(
None => String::new(),
};
let user_id_p = p + 1;
let limit_p = p + 2;
let offset_p = p + 3;
let missing_cte = r#"
missing_counts AS (
SELECT eml.series_name,
@@ -147,7 +154,7 @@ pub async fn list_series(
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name
),
{missing_cte},
@@ -160,9 +167,6 @@ pub async fn list_series(
"#
);
let limit_p = p + 1;
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH sorted_books AS (
@@ -186,7 +190,7 @@ pub async fn list_series(
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name
),
{missing_cte},
@@ -245,7 +249,8 @@ pub async fn list_series(
}
}
data_builder = data_builder.bind(limit).bind(offset);
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
@@ -327,8 +332,10 @@ pub struct ListAllSeriesQuery {
)]
pub async fn list_all_series(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Query(query): Query<ListAllSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
@@ -415,6 +422,10 @@ pub async fn list_all_series(
)
"#;
let user_id_p = p + 1;
let limit_p = p + 2;
let offset_p = p + 3;
let count_sql = format!(
r#"
WITH sorted_books AS (
@@ -426,7 +437,7 @@ pub async fn list_all_series(
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name, sb.library_id
),
{missing_cte},
@@ -445,9 +456,6 @@ pub async fn list_all_series(
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
};
let limit_p = p + 1;
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH sorted_books AS (
@@ -475,7 +483,7 @@ pub async fn list_all_series(
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
MAX(sb.updated_at) as latest_updated_at
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
GROUP BY sb.name, sb.library_id
),
{missing_cte},
@@ -538,7 +546,8 @@ pub async fn list_all_series(
data_builder = data_builder.bind(author.clone());
}
data_builder = data_builder.bind(limit).bind(offset);
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool),
@@ -642,8 +651,10 @@ pub struct OngoingQuery {
)]
pub async fn ongoing_series(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Query(query): Query<OngoingQuery>,
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(10).clamp(1, 50);
let rows = sqlx::query(
@@ -655,7 +666,7 @@ pub async fn ongoing_series(
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
MAX(brp.last_read_at) AS last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
HAVING (
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
@@ -685,6 +696,7 @@ pub async fn ongoing_series(
"#,
)
.bind(limit)
.bind(user_id)
.fetch_all(&state.pool)
.await?;
@@ -721,8 +733,10 @@ pub async fn ongoing_series(
)]
pub async fn ongoing_books(
State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Query(query): Query<OngoingQuery>,
) -> Result<Json<Vec<BookItem>>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let limit = query.limit.unwrap_or(10).clamp(1, 50);
let rows = sqlx::query(
@@ -732,7 +746,7 @@ pub async fn ongoing_books(
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
MAX(brp.last_read_at) AS series_last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
HAVING (
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
@@ -753,7 +767,7 @@ pub async fn ongoing_books(
) AS rn
FROM books b
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
WHERE COALESCE(brp.status, 'unread') != 'read'
)
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
@@ -765,6 +779,7 @@ pub async fn ongoing_books(
"#,
)
.bind(limit)
.bind(user_id)
.fetch_all(&state.pool)
.await?;