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

@@ -10,10 +10,15 @@ use sqlx::Row;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: uuid::Uuid,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Scope { pub enum Scope {
Admin, Admin,
Read, Read { user_id: uuid::Uuid },
} }
pub async fn require_admin( pub async fn require_admin(
@@ -40,6 +45,20 @@ pub async fn require_read(
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?; let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
let scope = authenticate(&state, token).await?; let scope = authenticate(&state, token).await?;
if let Scope::Read { user_id } = &scope {
req.extensions_mut().insert(AuthUser { user_id: *user_id });
} else if matches!(scope, Scope::Admin) {
// Admin peut s'impersonifier via le header X-As-User
if let Some(as_user_id) = req
.headers()
.get("X-As-User")
.and_then(|v| v.to_str().ok())
.and_then(|v| uuid::Uuid::parse_str(v).ok())
{
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
}
}
req.extensions_mut().insert(scope); req.extensions_mut().insert(scope);
Ok(next.run(req).await) Ok(next.run(req).await)
} }
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let maybe_row = sqlx::query( let maybe_row = sqlx::query(
r#" r#"
SELECT id, token_hash, scope SELECT id, token_hash, scope, user_id FROM api_tokens
FROM api_tokens
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
"#, "#,
) )
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?; let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
match scope.as_str() { match scope.as_str() {
"admin" => Ok(Scope::Admin), "admin" => Ok(Scope::Admin),
"read" => Ok(Scope::Read), "read" => {
let user_id: uuid::Uuid = row
.try_get("user_id")
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
Ok(Scope::Read { user_id })
}
_ => Err(ApiError::unauthorized("invalid token scope")), _ => Err(ApiError::unauthorized("invalid token scope")),
} }
} }

View File

@@ -1,11 +1,11 @@
use axum::{extract::{Path, Query, State}, Json}; use axum::{extract::{Extension, Path, Query, State}, Json};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use uuid::Uuid; use uuid::Uuid;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState}; use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state::AppState};
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct ListBooksQuery { pub struct ListBooksQuery {
@@ -122,7 +122,9 @@ pub struct BookDetails {
pub async fn list_books( pub async fn list_books(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<ListBooksQuery>, Query(query): Query<ListBooksQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<BooksPage>, ApiError> { ) -> Result<Json<BooksPage>, 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 limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1); let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit; let offset = (page - 1) * limit;
@@ -151,6 +153,8 @@ pub async fn list_books(
Some(_) => { p += 1; format!("AND eml.provider = ${p}") }, Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
None => String::new(), None => String::new(),
}; };
p += 1;
let uid_p = p;
let metadata_links_cte = r#" let metadata_links_cte = r#"
metadata_links AS ( metadata_links AS (
@@ -164,7 +168,7 @@ pub async fn list_books(
let count_sql = format!( let count_sql = format!(
r#"WITH {metadata_links_cte} r#"WITH {metadata_links_cte}
SELECT COUNT(*) FROM books b SELECT COUNT(*) 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 ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1) WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2) AND ($2::text IS NULL OR b.kind = $2)
@@ -192,7 +196,7 @@ pub async fn list_books(
brp.current_page AS reading_current_page, brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at brp.last_read_at AS reading_last_read_at
FROM books b 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 ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1) WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2) AND ($2::text IS NULL OR b.kind = $2)
@@ -235,8 +239,8 @@ pub async fn list_books(
data_builder = data_builder.bind(mp.clone()); data_builder = data_builder.bind(mp.clone());
} }
} }
count_builder = count_builder.bind(user_id);
data_builder = data_builder.bind(limit).bind(offset); data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!( let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool), count_builder.fetch_one(&state.pool),
@@ -295,7 +299,9 @@ pub async fn list_books(
pub async fn get_book( pub async fn get_book(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<BookDetails>, ApiError> { ) -> Result<Json<BookDetails>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let row = sqlx::query( let row = sqlx::query(
r#" r#"
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date, SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
@@ -311,11 +317,12 @@ pub async fn get_book(
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 1 LIMIT 1
) bf ON TRUE ) bf ON TRUE
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 b.id = $1 WHERE b.id = $1
"#, "#,
) )
.bind(id) .bind(id)
.bind(user_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await?; .await?;
@@ -521,9 +528,9 @@ pub async fn update_book(
WHERE id = $1 WHERE id = $1
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path, RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
summary, isbn, publish_date, summary, isbn, publish_date,
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status, 'unread' AS reading_status,
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page, NULL::integer AS reading_current_page,
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at NULL::timestamptz AS reading_last_read_at
"#, "#,
) )
.bind(id) .bind(id)

View File

@@ -38,6 +38,8 @@ pub struct KomgaSyncRequest {
pub url: String, pub url: String,
pub username: String, pub username: String,
pub password: String, pub password: String,
#[schema(value_type = String)]
pub user_id: Uuid,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
#[schema(value_type = String)] #[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
pub komga_url: String, pub komga_url: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub total_komga_read: i64, pub total_komga_read: i64,
pub matched: i64, pub matched: i64,
pub already_read: i64, pub already_read: i64,
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
#[schema(value_type = String)] #[schema(value_type = String)]
pub id: Uuid, pub id: Uuid,
pub komga_url: String, pub komga_url: String,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub total_komga_read: i64, pub total_komga_read: i64,
pub matched: i64, pub matched: i64,
pub already_read: i64, pub already_read: i64,
@@ -215,11 +221,12 @@ pub async fn sync_komga_read_books(
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new(); let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
if !matched_ids.is_empty() { if !matched_ids.is_empty() {
// Get already-read book IDs // Get already-read book IDs for this user
let ar_rows = sqlx::query( let ar_rows = sqlx::query(
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'", "SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
) )
.bind(&matched_ids) .bind(&matched_ids)
.bind(body.user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -228,12 +235,12 @@ pub async fn sync_komga_read_books(
} }
already_read = already_read_ids.len() as i64; already_read = already_read_ids.len() as i64;
// Bulk upsert all matched books as read // Bulk upsert all matched books as read for this user
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at) INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW() SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
ON CONFLICT (book_id) DO UPDATE ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read', SET status = 'read',
current_page = NULL, current_page = NULL,
last_read_at = NOW(), last_read_at = NOW(),
@@ -242,6 +249,7 @@ pub async fn sync_komga_read_books(
"#, "#,
) )
.bind(&matched_ids) .bind(&matched_ids)
.bind(body.user_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
} }
@@ -273,12 +281,13 @@ pub async fn sync_komga_read_books(
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default(); let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
let report_row = sqlx::query( let report_row = sqlx::query(
r#" r#"
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched) INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at RETURNING id, created_at
"#, "#,
) )
.bind(&url) .bind(&url)
.bind(body.user_id)
.bind(total_komga_read) .bind(total_komga_read)
.bind(matched) .bind(matched)
.bind(already_read) .bind(already_read)
@@ -292,6 +301,7 @@ pub async fn sync_komga_read_books(
Ok(Json(KomgaSyncResponse { Ok(Json(KomgaSyncResponse {
id: report_row.get("id"), id: report_row.get("id"),
komga_url: url, komga_url: url,
user_id: Some(body.user_id),
total_komga_read, total_komga_read,
matched, matched,
already_read, already_read,
@@ -319,7 +329,7 @@ pub async fn list_sync_reports(
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> { ) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
jsonb_array_length(unmatched) as unmatched_count, created_at jsonb_array_length(unmatched) as unmatched_count, created_at
FROM komga_sync_reports FROM komga_sync_reports
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -334,6 +344,7 @@ pub async fn list_sync_reports(
.map(|row| KomgaSyncReportSummary { .map(|row| KomgaSyncReportSummary {
id: row.get("id"), id: row.get("id"),
komga_url: row.get("komga_url"), komga_url: row.get("komga_url"),
user_id: row.get("user_id"),
total_komga_read: row.get("total_komga_read"), total_komga_read: row.get("total_komga_read"),
matched: row.get("matched"), matched: row.get("matched"),
already_read: row.get("already_read"), already_read: row.get("already_read"),
@@ -365,7 +376,7 @@ pub async fn get_sync_report(
) -> Result<Json<KomgaSyncResponse>, ApiError> { ) -> Result<Json<KomgaSyncResponse>, ApiError> {
let row = sqlx::query( let row = sqlx::query(
r#" r#"
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
FROM komga_sync_reports FROM komga_sync_reports
WHERE id = $1 WHERE id = $1
"#, "#,
@@ -386,6 +397,7 @@ pub async fn get_sync_report(
Ok(Json(KomgaSyncResponse { Ok(Json(KomgaSyncResponse {
id: row.get("id"), id: row.get("id"),
komga_url: row.get("komga_url"), komga_url: row.get("komga_url"),
user_id: row.get("user_id"),
total_komga_read: row.get("total_komga_read"), total_komga_read: row.get("total_komga_read"),
matched: row.get("matched"), matched: row.get("matched"),
already_read: row.get("already_read"), already_read: row.get("already_read"),

View File

@@ -25,6 +25,7 @@ mod stats;
mod telegram; mod telegram;
mod thumbnails; mod thumbnails;
mod tokens; mod tokens;
mod users;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -106,8 +107,10 @@ async fn main() -> anyhow::Result<()> {
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors)) .route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job)) .route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
.route("/folders", get(index_jobs::list_folders)) .route("/folders", get(index_jobs::list_folders))
.route("/admin/users", get(users::list_users).post(users::create_user))
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token)) .route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token)) .route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token)) .route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr)) .route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
.route("/prowlarr/test", get(prowlarr::test_prowlarr)) .route("/prowlarr/test", get(prowlarr::test_prowlarr))

View File

@@ -1,11 +1,11 @@
use axum::{extract::{Path, State}, Json}; use axum::{extract::{Extension, Path, State}, Json};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use uuid::Uuid; use uuid::Uuid;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState}; use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct ReadingProgressResponse { pub struct ReadingProgressResponse {
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
)] )]
pub async fn get_reading_progress( pub async fn get_reading_progress(
State(state): State<AppState>, State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<ReadingProgressResponse>, ApiError> { ) -> Result<Json<ReadingProgressResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
// Verify book exists // Verify book exists
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)") let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
.bind(id) .bind(id)
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
} }
let row = sqlx::query( let row = sqlx::query(
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1", "SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
) )
.bind(id) .bind(id)
.bind(auth_user.user_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await?; .await?;
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
)] )]
pub async fn update_reading_progress( pub async fn update_reading_progress(
State(state): State<AppState>, State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<UpdateReadingProgressRequest>, Json(body): Json<UpdateReadingProgressRequest>,
) -> Result<Json<ReadingProgressResponse>, ApiError> { ) -> Result<Json<ReadingProgressResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
// Validate status value // Validate status value
if !["unread", "reading", "read"].contains(&body.status.as_str()) { if !["unread", "reading", "read"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(format!( return Err(ApiError::bad_request(format!(
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
let row = sqlx::query( let row = sqlx::query(
r#" r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at) INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW()) VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (book_id) DO UPDATE ON CONFLICT (book_id, user_id) DO UPDATE
SET status = EXCLUDED.status, SET status = EXCLUDED.status,
current_page = EXCLUDED.current_page, current_page = EXCLUDED.current_page,
last_read_at = NOW(), last_read_at = NOW(),
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
"#, "#,
) )
.bind(id) .bind(id)
.bind(auth_user.user_id)
.bind(&body.status) .bind(&body.status)
.bind(current_page) .bind(current_page)
.fetch_one(&state.pool) .fetch_one(&state.pool)
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
)] )]
pub async fn mark_series_read( pub async fn mark_series_read(
State(state): State<AppState>, State(state): State<AppState>,
user: Option<Extension<AuthUser>>,
Json(body): Json<MarkSeriesReadRequest>, Json(body): Json<MarkSeriesReadRequest>,
) -> Result<Json<MarkSeriesReadResponse>, ApiError> { ) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
if !["read", "unread"].contains(&body.status.as_str()) { if !["read", "unread"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
"status must be 'read' or 'unread'", "status must be 'read' or 'unread'",
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
}; };
let sql = if body.status == "unread" { let sql = if body.status == "unread" {
// Delete progress records to reset to unread // Delete progress records to reset to unread (scoped to this user)
if body.series == "unclassified" {
format!( format!(
r#" r#"
WITH target_books AS ( WITH target_books AS (
SELECT id FROM books WHERE {series_filter} SELECT id FROM books WHERE {series_filter}
) )
DELETE FROM book_reading_progress DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books) WHERE book_id IN (SELECT id FROM target_books) AND user_id = $1
"# "#
) )
} else { } else {
format!( format!(
r#" r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at) WITH target_books AS (
SELECT id, 'read', NULL, NOW(), NOW() SELECT id FROM books WHERE {series_filter}
)
DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $2
"#
)
}
} else if body.series == "unclassified" {
format!(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT id, $1, 'read', NULL, NOW(), NOW()
FROM books FROM books
WHERE {series_filter} WHERE {series_filter}
ON CONFLICT (book_id) DO UPDATE ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
updated_at = NOW()
"#
)
} else {
format!(
r#"
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
SELECT id, $2, 'read', NULL, NOW(), NOW()
FROM books
WHERE {series_filter}
ON CONFLICT (book_id, user_id) DO UPDATE
SET status = 'read', SET status = 'read',
current_page = NULL, current_page = NULL,
last_read_at = NOW(), last_read_at = NOW(),
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
}; };
let result = if body.series == "unclassified" { let result = if body.series == "unclassified" {
sqlx::query(&sql).execute(&state.pool).await? // $1 = user_id (no series bind needed)
sqlx::query(&sql)
.bind(auth_user.user_id)
.execute(&state.pool)
.await?
} else { } else {
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await? // $1 = series, $2 = user_id
sqlx::query(&sql)
.bind(&body.series)
.bind(auth_user.user_id)
.execute(&state.pool)
.await?
}; };
Ok(Json(MarkSeriesReadResponse { Ok(Json(MarkSeriesReadResponse {

View File

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

View File

@@ -1,12 +1,12 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Extension, Query, State},
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use crate::{error::ApiError, state::AppState}; use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)] #[derive(Deserialize, IntoParams)]
pub struct StatsQuery { pub struct StatsQuery {
@@ -90,6 +90,7 @@ pub struct CurrentlyReadingItem {
pub series: Option<String>, pub series: Option<String>,
pub current_page: i32, pub current_page: i32,
pub page_count: i32, pub page_count: i32,
pub username: Option<String>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -98,6 +99,7 @@ pub struct RecentlyReadItem {
pub title: String, pub title: String,
pub series: Option<String>, pub series: Option<String>,
pub last_read_at: String, pub last_read_at: String,
pub username: Option<String>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -106,6 +108,13 @@ pub struct MonthlyReading {
pub books_read: i64, pub books_read: i64,
} }
#[derive(Serialize, ToSchema)]
pub struct UserMonthlyReading {
pub month: String,
pub username: String,
pub books_read: i64,
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct JobTimePoint { pub struct JobTimePoint {
pub label: String, pub label: String,
@@ -129,6 +138,7 @@ pub struct StatsResponse {
pub additions_over_time: Vec<MonthlyAdditions>, pub additions_over_time: Vec<MonthlyAdditions>,
pub jobs_over_time: Vec<JobTimePoint>, pub jobs_over_time: Vec<JobTimePoint>,
pub metadata: MetadataStats, pub metadata: MetadataStats,
pub users_reading_over_time: Vec<UserMonthlyReading>,
} }
/// Get collection statistics for the dashboard /// Get collection statistics for the dashboard
@@ -146,7 +156,9 @@ pub struct StatsResponse {
pub async fn get_stats( pub async fn get_stats(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<StatsQuery>, Query(query): Query<StatsQuery>,
user: Option<Extension<AuthUser>>,
) -> Result<Json<StatsResponse>, ApiError> { ) -> Result<Json<StatsResponse>, ApiError> {
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
let period = query.period.as_deref().unwrap_or("month"); let period = query.period.as_deref().unwrap_or("month");
// Overview + reading status in one query // Overview + reading status in one query
let overview_row = sqlx::query( let overview_row = sqlx::query(
@@ -165,9 +177,10 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading, COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
COUNT(*) FILTER (WHERE brp.status = 'read') AS read COUNT(*) FILTER (WHERE brp.status = 'read') AS read
FROM books b 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 ($1::uuid IS NULL OR brp.user_id = $1)
"#, "#,
) )
.bind(user_id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
@@ -255,7 +268,7 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
FROM libraries l FROM libraries l
LEFT JOIN books b ON b.library_id = l.id LEFT JOIN books b ON b.library_id = l.id
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 ($1::uuid IS NULL OR brp.user_id = $1)
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1 SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
) bf ON TRUE ) bf ON TRUE
@@ -263,6 +276,7 @@ pub async fn get_stats(
ORDER BY book_count DESC ORDER BY book_count DESC
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -287,13 +301,14 @@ pub async fn get_stats(
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count, COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
FROM books b 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 ($1::uuid IS NULL OR brp.user_id = $1)
WHERE b.series IS NOT NULL AND b.series != '' WHERE b.series IS NOT NULL AND b.series != ''
GROUP BY b.series GROUP BY b.series
ORDER BY book_count DESC ORDER BY book_count DESC
LIMIT 10 LIMIT 10
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -432,14 +447,17 @@ pub async fn get_stats(
// Currently reading books // Currently reading books
let reading_rows = sqlx::query( let reading_rows = sqlx::query(
r#" r#"
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
FROM book_reading_progress brp FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.updated_at DESC ORDER BY brp.updated_at DESC
LIMIT 20 LIMIT 20
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -453,6 +471,7 @@ pub async fn get_stats(
series: r.get("series"), series: r.get("series"),
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0), current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0), page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
username: r.get("username"),
} }
}) })
.collect(); .collect();
@@ -461,14 +480,18 @@ pub async fn get_stats(
let recent_rows = sqlx::query( let recent_rows = sqlx::query(
r#" r#"
SELECT b.id AS book_id, b.title, b.series, SELECT b.id AS book_id, b.title, b.series,
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
u.username
FROM book_reading_progress brp FROM book_reading_progress brp
JOIN books b ON b.id = brp.book_id JOIN books b ON b.id = brp.book_id
LEFT JOIN users u ON u.id = brp.user_id
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
AND ($1::uuid IS NULL OR brp.user_id = $1)
ORDER BY brp.last_read_at DESC ORDER BY brp.last_read_at DESC
LIMIT 10 LIMIT 10
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -481,6 +504,7 @@ pub async fn get_stats(
title: r.get("title"), title: r.get("title"),
series: r.get("series"), series: r.get("series"),
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(), last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
username: r.get("username"),
} }
}) })
.collect(); .collect();
@@ -499,11 +523,13 @@ pub async fn get_stats(
FROM book_reading_progress brp FROM book_reading_progress brp
WHERE brp.status = 'read' WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days' AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY brp.last_read_at::date GROUP BY brp.last_read_at::date
) cnt ON cnt.dt = d.dt ) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await? .await?
} }
@@ -523,11 +549,13 @@ pub async fn get_stats(
FROM book_reading_progress brp FROM book_reading_progress brp
WHERE brp.status = 'read' WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months') AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('week', brp.last_read_at) GROUP BY DATE_TRUNC('week', brp.last_read_at)
) cnt ON cnt.dt = d.dt ) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await? .await?
} }
@@ -547,11 +575,13 @@ pub async fn get_stats(
FROM book_reading_progress brp FROM book_reading_progress brp
WHERE brp.status = 'read' WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('month', brp.last_read_at) GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt ) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await? .await?
} }
@@ -565,6 +595,93 @@ pub async fn get_stats(
}) })
.collect(); .collect();
// Per-user reading over time (admin view — always all users, no user_id filter)
let users_reading_time_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY brp.last_read_at::date, brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
u.username,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
CROSS JOIN users u
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
ORDER BY month ASC, u.username
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let users_reading_over_time: Vec<UserMonthlyReading> = users_reading_time_rows
.iter()
.map(|r| UserMonthlyReading {
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
username: r.get("username"),
books_read: r.get("books_read"),
})
.collect();
// Jobs over time (with gap filling, grouped by type category) // Jobs over time (with gap filling, grouped by type category)
let jobs_rows = match period { let jobs_rows = match period {
"day" => { "day" => {
@@ -697,5 +814,6 @@ pub async fn get_stats(
additions_over_time, additions_over_time,
jobs_over_time, jobs_over_time,
metadata, metadata,
users_reading_over_time,
})) }))
} }

View File

@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
pub name: String, pub name: String,
#[schema(value_type = Option<String>, example = "read")] #[schema(value_type = Option<String>, example = "read")]
pub scope: Option<String>, pub scope: Option<String>,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -26,6 +28,9 @@ pub struct TokenResponse {
pub scope: String, pub scope: String,
pub prefix: String, pub prefix: String,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
pub username: Option<String>,
#[schema(value_type = Option<String>)]
pub last_used_at: Option<DateTime<Utc>>, pub last_used_at: Option<DateTime<Utc>>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<String>)]
pub revoked_at: Option<DateTime<Utc>>, pub revoked_at: Option<DateTime<Utc>>,
@@ -71,6 +76,10 @@ pub async fn create_token(
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")), _ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
}; };
if scope == "read" && input.user_id.is_none() {
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
}
let mut random = [0u8; 24]; let mut random = [0u8; 24];
OsRng.fill_bytes(&mut random); OsRng.fill_bytes(&mut random);
let secret = URL_SAFE_NO_PAD.encode(random); let secret = URL_SAFE_NO_PAD.encode(random);
@@ -85,13 +94,14 @@ pub async fn create_token(
let id = Uuid::new_v4(); let id = Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)", "INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(id) .bind(id)
.bind(input.name.trim()) .bind(input.name.trim())
.bind(&prefix) .bind(&prefix)
.bind(token_hash) .bind(token_hash)
.bind(scope) .bind(scope)
.bind(input.user_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
@@ -118,7 +128,13 @@ pub async fn create_token(
)] )]
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> { pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC", r#"
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
t.last_used_at, t.revoked_at, t.created_at
FROM api_tokens t
LEFT JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
"#,
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
name: row.get("name"), name: row.get("name"),
scope: row.get("scope"), scope: row.get("scope"),
prefix: row.get("prefix"), prefix: row.get("prefix"),
user_id: row.get("user_id"),
username: row.get("username"),
last_used_at: row.get("last_used_at"), last_used_at: row.get("last_used_at"),
revoked_at: row.get("revoked_at"), revoked_at: row.get("revoked_at"),
created_at: row.get("created_at"), created_at: row.get("created_at"),
@@ -171,6 +189,47 @@ pub async fn revoke_token(
Ok(Json(serde_json::json!({"revoked": true, "id": id}))) Ok(Json(serde_json::json!({"revoked": true, "id": id})))
} }
#[derive(Deserialize, ToSchema)]
pub struct UpdateTokenRequest {
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
}
/// Update a token's assigned user
#[utoipa::path(
patch,
path = "/admin/tokens/{id}",
tag = "tokens",
params(
("id" = String, Path, description = "Token UUID"),
),
request_body = UpdateTokenRequest,
responses(
(status = 200, description = "Token updated"),
(status = 404, description = "Token not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTokenRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
.bind(input.user_id)
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("token not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
}
/// Permanently delete a revoked API token /// Permanently delete a revoked API token
#[utoipa::path( #[utoipa::path(
post, post,

195
apps/api/src/users.rs Normal file
View File

@@ -0,0 +1,195 @@
use axum::{extract::{Path, State}, Json};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use uuid::Uuid;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState};
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
#[schema(value_type = String)]
pub id: Uuid,
pub username: String,
pub token_count: i64,
pub books_read: i64,
pub books_reading: i64,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>,
}
#[derive(Deserialize, ToSchema)]
pub struct CreateUserRequest {
pub username: String,
}
/// List all reader users with their associated token count
#[utoipa::path(
get,
path = "/admin/users",
tag = "users",
responses(
(status = 200, body = Vec<UserResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
let rows = sqlx::query(
r#"
SELECT u.id, u.username, u.created_at,
COUNT(DISTINCT t.id) AS token_count,
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
FROM users u
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
GROUP BY u.id, u.username, u.created_at
ORDER BY u.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await?;
let items = rows
.into_iter()
.map(|row| UserResponse {
id: row.get("id"),
username: row.get("username"),
token_count: row.get("token_count"),
books_read: row.get("books_read"),
books_reading: row.get("books_reading"),
created_at: row.get("created_at"),
})
.collect();
Ok(Json(items))
}
/// Create a new reader user
#[utoipa::path(
post,
path = "/admin/users",
tag = "users",
request_body = CreateUserRequest,
responses(
(status = 200, body = UserResponse, description = "User created"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn create_user(
State(state): State<AppState>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
let id = Uuid::new_v4();
let row = sqlx::query(
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
)
.bind(id)
.bind(input.username.trim())
.fetch_one(&state.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.constraint() == Some("users_username_key") {
return ApiError::bad_request("username already exists");
}
}
ApiError::from(e)
})?;
Ok(Json(UserResponse {
id: row.get("id"),
username: row.get("username"),
token_count: 0,
books_read: 0,
books_reading: 0,
created_at: row.get("created_at"),
}))
}
/// Update a reader user's username
#[utoipa::path(
patch,
path = "/admin/users/{id}",
tag = "users",
request_body = CreateUserRequest,
responses(
(status = 200, body = UserResponse, description = "User updated"),
(status = 400, description = "Invalid input"),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
.bind(input.username.trim())
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.constraint() == Some("users_username_key") {
return ApiError::bad_request("username already exists");
}
}
ApiError::from(e)
})?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
}
/// Delete a reader user (cascades on tokens and reading progress)
#[utoipa::path(
delete,
path = "/admin/users/{id}",
tag = "users",
params(
("id" = String, Path, description = "User UUID"),
),
responses(
(status = 200, description = "User deleted"),
(status = 404, description = "User not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn delete_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let result = sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
}

View File

@@ -1,11 +1,15 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { ThemeToggle } from "@/app/theme-toggle"; import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator"; import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui"; import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton"; import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav"; import { MobileNav } from "@/app/components/MobileNav";
import { UserSwitcher } from "@/app/components/UserSwitcher";
import { fetchUsers } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr"; import type { TranslationKey } from "@/lib/i18n/fr";
@@ -27,6 +31,21 @@ const navItems: NavItem[] = [
export default async function AppLayout({ children }: { children: ReactNode }) { export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations(); 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 ( 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" /> <Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span> <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")} {t("common.backoffice")}
</span> </span>
</div> </div>
@@ -50,16 +69,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}> <NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} /> <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> </NavLink>
))} ))}
</div> </div>
<UserSwitcher
users={users}
activeUserId={activeUserId}
setActiveUserAction={setActiveUserAction}
/>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60"> <div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator /> <JobsIndicator />
<Link <Link
href="/settings" 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")} title={t("nav.settings")}
> >
<Icon name="settings" size="md" /> <Icon name="settings" size="md" />

View File

@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { fetchStats, StatsResponse, getBookCoverUrl } from "@/lib/api"; import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui"; import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts"; import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
import { PeriodToggle } from "@/app/components/PeriodToggle"; import { PeriodToggle } from "@/app/components/PeriodToggle";
import Image from "next/image"; import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
import Link from "next/link"; import Link from "next/link";
import { getServerTranslations } from "@/lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslateFunction } from "@/lib/i18n/dictionaries"; import type { TranslateFunction } from "@/lib/i18n/dictionaries";
@@ -70,8 +70,12 @@ export default async function DashboardPage({
const { t, locale } = await getServerTranslations(); const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null; let stats: StatsResponse | null = null;
let users: UserDto[] = [];
try { try {
stats = await fetchStats(period); [stats, users] = await Promise.all([
fetchStats(period),
fetchUsers().catch(() => []),
]);
} catch (e) { } catch (e) {
console.error("Failed to fetch stats:", e); console.error("Failed to fetch stats:", e);
} }
@@ -94,6 +98,7 @@ export default async function DashboardPage({
currently_reading = [], currently_reading = [],
recently_read = [], recently_read = [],
reading_over_time = [], reading_over_time = [],
users_reading_over_time = [],
by_format, by_format,
by_library, by_library,
top_series, top_series,
@@ -145,37 +150,12 @@ export default async function DashboardPage({
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle> <CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{currently_reading.length === 0 ? ( <CurrentlyReadingList
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p> items={currently_reading}
) : ( allLabel={t("dashboard.allUsers")}
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1"> emptyLabel={t("dashboard.noCurrentlyReading")}
{currently_reading.slice(0, 8).map((book) => { pageProgressTemplate={t("dashboard.pageProgress")}
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return (
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/> />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{t("dashboard.pageProgress", { current: book.current_page, total: book.page_count })}</p>
</div>
</Link>
);
})}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -185,28 +165,11 @@ export default async function DashboardPage({
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle> <CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recently_read.length === 0 ? ( <RecentlyReadList
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p> items={recently_read}
) : ( allLabel={t("dashboard.allUsers")}
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1"> emptyLabel={t("dashboard.noRecentlyRead")}
{recently_read.map((book) => (
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/> />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
</div>
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
</Link>
))}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -219,22 +182,48 @@ export default async function DashboardPage({
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} /> <PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{(() => {
const userColors = [
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
];
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
if (usernames.length === 0) {
return (
<RcAreaChart <RcAreaChart
noDataLabel={noDataLabel} noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))} data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
color="hsl(142 60% 45%)" color="hsl(142 60% 45%)"
/> />
);
}
// Pivot: { label, username1: n, username2: n, ... }
const byMonth = new Map<string, Record<string, unknown>>();
for (const row of users_reading_over_time) {
const label = formatChartLabel(row.month, period, locale);
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
byMonth.get(row.month)![row.username] = row.books_read;
}
const chartData = [...byMonth.values()];
const lines = usernames.map((u, i) => ({
key: u,
label: u,
color: userColors[i % userColors.length],
}));
return <RcMultiLineChart data={chartData} lines={lines} noDataLabel={noDataLabel} />;
})()}
</CardContent> </CardContent>
</Card> </Card>
{/* Charts row */} {/* Charts row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Reading status donut */} {/* Reading status par lecteur */}
<Card hover={false}> <Card hover={false}>
<CardHeader> <CardHeader>
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle> <CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{users.length === 0 ? (
<RcDonutChart <RcDonutChart
noDataLabel={noDataLabel} noDataLabel={noDataLabel}
data={[ data={[
@@ -243,6 +232,34 @@ export default async function DashboardPage({
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] }, { name: t("status.read"), value: reading_status.read, color: readingColors[2] },
]} ]}
/> />
) : (
<div className="space-y-3">
{users.map((user) => {
const total = overview.total_books;
const read = user.books_read;
const reading = user.books_reading;
const unread = Math.max(0, total - read - reading);
const readPct = total > 0 ? (read / total) * 100 : 0;
const readingPct = total > 0 ? (reading / total) * 100 : 0;
return (
<div key={user.id} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground truncate">{user.username}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
<span className="text-success font-medium">{read}</span>
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
<span className="text-muted-foreground/60"> / {total}</span>
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
</div>
</div>
);
})}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
import { ProviderIcon } from "@/app/components/ProviderIcon"; import { ProviderIcon } from "@/app/components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "@/lib/api"; import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } from "@/lib/api";
import { useTranslation } from "@/lib/i18n/context"; import { useTranslation } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/types"; import type { Locale } from "@/lib/i18n/types";
@@ -11,9 +11,10 @@ interface SettingsPageProps {
initialSettings: Settings; initialSettings: Settings;
initialCacheStats: CacheStats; initialCacheStats: CacheStats;
initialThumbnailStats: ThumbnailStats; initialThumbnailStats: ThumbnailStats;
users: UserDto[];
} }
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) { export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) {
const { t, locale, setLocale } = useTranslation(); const { t, locale, setLocale } = useTranslation();
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
...initialSettings, ...initialSettings,
@@ -29,6 +30,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const [komgaUrl, setKomgaUrl] = useState(""); const [komgaUrl, setKomgaUrl] = useState("");
const [komgaUsername, setKomgaUsername] = useState(""); const [komgaUsername, setKomgaUsername] = useState("");
const [komgaPassword, setKomgaPassword] = useState(""); const [komgaPassword, setKomgaPassword] = useState("");
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null); const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
const [syncError, setSyncError] = useState<string | null>(null); const [syncError, setSyncError] = useState<string | null>(null);
@@ -104,6 +106,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
if (data) { if (data) {
if (data.url) setKomgaUrl(data.url); if (data.url) setKomgaUrl(data.url);
if (data.username) setKomgaUsername(data.username); if (data.username) setKomgaUsername(data.username);
if (data.user_id) setKomgaUserId(data.user_id);
} }
}).catch(() => {}); }).catch(() => {});
}, [fetchReports]); }, [fetchReports]);
@@ -128,7 +131,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const response = await fetch("/api/komga/sync", { const response = await fetch("/api/komga/sync", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }), body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
@@ -140,7 +143,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
fetch("/api/settings/komga", { fetch("/api/settings/komga", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }), body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }),
}).catch(() => {}); }).catch(() => {});
} }
} catch { } catch {
@@ -627,9 +630,22 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</FormField> </FormField>
</FormRow> </FormRow>
{users.length > 0 && (
<FormRow>
<FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("users.title")}</label>
<FormSelect value={komgaUserId} onChange={(e) => setKomgaUserId(e.target.value)}>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</FormSelect>
</FormField>
</FormRow>
)}
<Button <Button
onClick={handleKomgaSync} onClick={handleKomgaSync}
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword} disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
> >
{isSyncing ? ( {isSyncing ? (
<> <>

View File

@@ -1,4 +1,4 @@
import { getSettings, getCacheStats, getThumbnailStats } from "@/lib/api"; import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
import SettingsPage from "./SettingsPage"; import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -23,5 +23,7 @@ export default async function SettingsPageWrapper() {
directory: "/data/thumbnails" directory: "/data/thumbnails"
})); }));
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />; const users = await fetchUsers().catch(() => []);
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
} }

View File

@@ -1,7 +1,9 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "@/lib/api"; import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
import { UsernameEdit } from "@/app/components/UsernameEdit";
import { getServerTranslations } from "@/lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,13 +16,15 @@ export default async function TokensPage({
const { t } = await getServerTranslations(); const { t } = await getServerTranslations();
const params = await searchParams; const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]); const tokens = await listTokens().catch(() => [] as TokenDto[]);
const users = await fetchUsers().catch(() => [] as UserDto[]);
async function createTokenAction(formData: FormData) { async function createTokenAction(formData: FormData) {
"use server"; "use server";
const name = formData.get("name") as string; const name = formData.get("name") as string;
const scope = formData.get("scope") as string; const scope = formData.get("scope") as string;
const userId = (formData.get("user_id") as string) || undefined;
if (name) { if (name) {
const result = await createToken(name, scope); const result = await createToken(name, scope, userId);
revalidatePath("/tokens"); revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`); redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
} }
@@ -40,6 +44,40 @@ export default async function TokensPage({
revalidatePath("/tokens"); revalidatePath("/tokens");
} }
async function createUserAction(formData: FormData) {
"use server";
const username = formData.get("username") as string;
if (username) {
await createUser(username);
revalidatePath("/tokens");
}
}
async function deleteUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteUser(id);
revalidatePath("/tokens");
}
async function renameUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const username = formData.get("username") as string;
if (username?.trim()) {
await updateUser(id, username.trim());
revalidatePath("/tokens");
}
}
async function reassignTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const userId = (formData.get("user_id") as string) || null;
await updateToken(id, userId);
revalidatePath("/tokens");
}
return ( return (
<> <>
<div className="mb-6"> <div className="mb-6">
@@ -51,6 +89,115 @@ export default async function TokensPage({
</h1> </h1>
</div> </div>
{/* ── Lecteurs ─────────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("users.createNew")}</CardTitle>
<CardDescription>{t("users.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createUserAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
</FormField>
<Button type="submit">{t("users.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden mb-10">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{/* Ligne admin synthétique */}
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
{process.env.ADMIN_USERNAME ?? "admin"}
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
{/* Ligne tokens read non assignés */}
{(() => {
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
if (unassigned.length === 0) return null;
return (
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
{t("tokens.noUser")}
</td>
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
);
})()}
{users.map((user) => (
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3">
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
<td className="px-4 py-3 text-sm">
{user.books_read > 0
? <span className="font-medium text-success">{user.books_read}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm">
{user.books_reading > 0
? <span className="font-medium text-amber-500">{user.books_reading}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<form action={deleteUserAction}>
<input type="hidden" name="id" value={user.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* ── Tokens API ───────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("tokens.apiTokens")}</h2>
</div>
{params.created ? ( {params.created ? (
<Card className="mb-6 border-success/50 bg-success/5"> <Card className="mb-6 border-success/50 bg-success/5">
<CardHeader> <CardHeader>
@@ -72,7 +219,7 @@ export default async function TokensPage({
<form action={createTokenAction}> <form action={createTokenAction}>
<FormRow> <FormRow>
<FormField className="flex-1 min-w-48"> <FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder={t("tokens.tokenName")} required /> <FormInput name="name" placeholder={t("tokens.tokenName")} required autoComplete="off" />
</FormField> </FormField>
<FormField className="w-32"> <FormField className="w-32">
<FormSelect name="scope" defaultValue="read"> <FormSelect name="scope" defaultValue="read">
@@ -80,6 +227,14 @@ export default async function TokensPage({
<option value="admin">{t("tokens.scopeAdmin")}</option> <option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect> </FormSelect>
</FormField> </FormField>
<FormField className="w-48">
<FormSelect name="user_id" defaultValue="">
<option value="">{t("tokens.noUser")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.username}</option>
))}
</FormSelect>
</FormField>
<Button type="submit">{t("tokens.createButton")}</Button> <Button type="submit">{t("tokens.createButton")}</Button>
</FormRow> </FormRow>
</form> </form>
@@ -92,6 +247,7 @@ export default async function TokensPage({
<thead> <thead>
<tr className="border-b border-border/60 bg-muted/50"> <tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.user")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
@@ -102,6 +258,15 @@ export default async function TokensPage({
{tokens.map((token) => ( {tokens.map((token) => (
<tr key={token.id} className="hover:bg-accent/50 transition-colors"> <tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td> <td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm">
<TokenUserSelect
tokenId={token.id}
currentUserId={token.user_id}
users={users}
action={reassignTokenAction}
noUserLabel={t("tokens.noUser")}
/>
</td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}> <Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope} {token.scope}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
import { getBookCoverUrl } from "@/lib/api";
function FilterPills({ usernames, selected, allLabel, onSelect }: {
usernames: string[];
selected: string | null;
allLabel: string;
onSelect: (u: string | null) => void;
}) {
if (usernames.length <= 1) return null;
return (
<div className="flex flex-wrap gap-1.5 mb-3">
<button
onClick={() => onSelect(null)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === null
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{allLabel}
</button>
{usernames.map((u) => (
<button
key={u}
onClick={() => onSelect(u === selected ? null : u)}
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
selected === u
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{u}
</button>
))}
</div>
);
}
export function CurrentlyReadingList({
items,
allLabel,
emptyLabel,
pageProgressTemplate,
}: {
items: CurrentlyReadingItem[];
allLabel: string;
emptyLabel: string;
/** Template with {{current}} and {{total}} placeholders */
pageProgressTemplate: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
export function RecentlyReadList({
items,
allLabel,
emptyLabel,
}: {
items: RecentlyReadItem[];
allLabel: string;
emptyLabel: string;
}) {
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
const [selected, setSelected] = useState<string | null>(null);
const filtered = selected ? items.filter((i) => i.username === selected) : items;
return (
<div>
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
{filtered.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{filtered.map((book) => (
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image
src={getBookCoverUrl(book.book_id)}
alt={book.title}
width={40}
height={56}
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
{book.username && usernames.length > 1 && (
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useOptimistic, useTransition } from "react";
interface TokenUserSelectProps {
tokenId: string;
currentUserId?: string;
users: { id: string; username: string }[];
action: (formData: FormData) => Promise<void>;
noUserLabel: string;
}
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
const [, startTransition] = useTransition();
return (
<select
value={optimisticValue}
onChange={(e) => {
const newValue = e.target.value;
startTransition(async () => {
setOptimisticValue(newValue);
const fd = new FormData();
fd.append("id", tokenId);
fd.append("user_id", newValue);
await action(fd);
});
}}
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">{noUserLabel}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import type { UserDto } from "@/lib/api";
export function UserSwitcher({
users,
activeUserId,
setActiveUserAction,
}: {
users: UserDto[];
activeUserId: string | null;
setActiveUserAction: (formData: FormData) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [, startTransition] = useTransition();
const ref = useRef<HTMLDivElement>(null);
const activeUser = users.find((u) => u.id === activeUserId) ?? null;
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function select(userId: string | null) {
setOpen(false);
startTransition(async () => {
const fd = new FormData();
fd.append("user_id", userId ?? "");
await setActiveUserAction(fd);
});
}
if (users.length === 0) return null;
const isImpersonating = activeUserId !== null;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
isImpersonating
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
: "border-border/60 bg-muted/40 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{isImpersonating ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)}
<span className="max-w-[80px] truncate hidden sm:inline">
{activeUser ? activeUser.username : "Admin"}
</span>
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border border-border/60 bg-popover shadow-lg z-50 overflow-hidden py-1">
<button
onClick={() => select(null)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
!isImpersonating
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Admin
{!isImpersonating && (
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="h-px bg-border/60 my-1" />
{users.map((user) => (
<button
key={user.id}
onClick={() => select(user.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
activeUserId === user.id
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="truncate">{user.username}</span>
{activeUserId === user.id && (
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useOptimistic, useTransition, useRef, useState } from "react";
export function UsernameEdit({
userId,
currentUsername,
action,
}: {
userId: string;
currentUsername: string;
action: (formData: FormData) => Promise<void>;
}) {
const [optimisticUsername, setOptimisticUsername] = useOptimistic(currentUsername);
const [editing, setEditing] = useState(false);
const [, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
function startEdit() {
setEditing(true);
setTimeout(() => inputRef.current?.select(), 0);
}
function submit(value: string) {
const trimmed = value.trim();
if (!trimmed || trimmed === currentUsername) {
setEditing(false);
return;
}
setEditing(false);
startTransition(async () => {
setOptimisticUsername(trimmed);
const fd = new FormData();
fd.append("id", userId);
fd.append("username", trimmed);
await action(fd);
});
}
if (editing) {
return (
<input
ref={inputRef}
defaultValue={optimisticUsername}
className="text-sm font-medium text-foreground bg-background border border-border rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary w-36"
autoFocus
onBlur={(e) => submit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit((e.target as HTMLInputElement).value);
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
onClick={startEdit}
className="flex items-center gap-1.5 group/edit text-left"
title="Modifier"
>
<span className="text-sm font-medium text-foreground">{optimisticUsername}</span>
<svg
className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover/edit:opacity-100 transition-opacity shrink-0"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
);
}

View File

@@ -45,6 +45,17 @@ export type TokenDto = {
scope: string; scope: string;
prefix: string; prefix: string;
revoked_at: string | null; 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 = { export type FolderItem = {
@@ -151,6 +162,16 @@ export async function apiFetch<T>(
headers.set("Content-Type", "application/json"); 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 { next: nextOptions, ...restInit } = init ?? {};
const res = await fetch(`${baseUrl}${path}`, { const res = await fetch(`${baseUrl}${path}`, {
@@ -268,10 +289,32 @@ export async function listTokens() {
return apiFetch<TokenDto[]>("/admin/tokens"); 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", { return apiFetch<{ token: string }>("/admin/tokens", {
method: "POST", 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" }); 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( export async function fetchBooks(
libraryId?: string, libraryId?: string,
series?: string, series?: string,
@@ -557,6 +607,7 @@ export type CurrentlyReadingItem = {
series: string | null; series: string | null;
current_page: number; current_page: number;
page_count: number; page_count: number;
username?: string;
}; };
export type RecentlyReadItem = { export type RecentlyReadItem = {
@@ -564,6 +615,7 @@ export type RecentlyReadItem = {
title: string; title: string;
series: string | null; series: string | null;
last_read_at: string; last_read_at: string;
username?: string;
}; };
export type MonthlyReading = { export type MonthlyReading = {
@@ -571,6 +623,12 @@ export type MonthlyReading = {
books_read: number; books_read: number;
}; };
export type UserMonthlyReading = {
month: string;
username: string;
books_read: number;
};
export type JobTimePoint = { export type JobTimePoint = {
label: string; label: string;
scan: number; scan: number;
@@ -585,6 +643,7 @@ export type StatsResponse = {
currently_reading: CurrentlyReadingItem[]; currently_reading: CurrentlyReadingItem[];
recently_read: RecentlyReadItem[]; recently_read: RecentlyReadItem[];
reading_over_time: MonthlyReading[]; reading_over_time: MonthlyReading[];
users_reading_over_time: UserMonthlyReading[];
by_format: FormatCount[]; by_format: FormatCount[];
by_language: LanguageCount[]; by_language: LanguageCount[];
by_library: LibraryStatsItem[]; by_library: LibraryStatsItem[];
@@ -699,11 +758,13 @@ export type KomgaSyncRequest = {
url: string; url: string;
username: string; username: string;
password: string; password: string;
user_id: string;
}; };
export type KomgaSyncResponse = { export type KomgaSyncResponse = {
id: string; id: string;
komga_url: string; komga_url: string;
user_id?: string;
total_komga_read: number; total_komga_read: number;
matched: number; matched: number;
already_read: number; already_read: number;
@@ -717,6 +778,7 @@ export type KomgaSyncResponse = {
export type KomgaSyncReportSummary = { export type KomgaSyncReportSummary = {
id: string; id: string;
komga_url: string; komga_url: string;
user_id?: string;
total_komga_read: number; total_komga_read: number;
matched: number; matched: number;
already_read: number; already_read: number;

View File

@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
"nav.libraries": "Libraries", "nav.libraries": "Libraries",
"nav.jobs": "Jobs", "nav.jobs": "Jobs",
"nav.tokens": "Tokens", "nav.tokens": "Tokens",
"nav.users": "Users",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.navigation": "Navigation", "nav.navigation": "Navigation",
"nav.closeMenu": "Close menu", "nav.closeMenu": "Close menu",
@@ -96,6 +97,7 @@ const en: Record<TranslationKey, string> = {
"dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "No books in progress", "dashboard.noCurrentlyReading": "No books in progress",
"dashboard.noRecentlyRead": "No books read recently", "dashboard.noRecentlyRead": "No books read recently",
"dashboard.allUsers": "All",
// Books page // Books page
"books.title": "Books", "books.title": "Books",
@@ -405,6 +407,21 @@ const en: Record<TranslationKey, string> = {
"tokens.revoked": "Revoked", "tokens.revoked": "Revoked",
"tokens.active": "Active", "tokens.active": "Active",
"tokens.revoke": "Revoke", "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 page
"settings.title": "Settings", "settings.title": "Settings",

View File

@@ -6,6 +6,7 @@ const fr = {
"nav.libraries": "Bibliothèques", "nav.libraries": "Bibliothèques",
"nav.jobs": "Tâches", "nav.jobs": "Tâches",
"nav.tokens": "Jetons", "nav.tokens": "Jetons",
"nav.users": "Utilisateurs",
"nav.settings": "Paramètres", "nav.settings": "Paramètres",
"nav.navigation": "Navigation", "nav.navigation": "Navigation",
"nav.closeMenu": "Fermer le menu", "nav.closeMenu": "Fermer le menu",
@@ -94,6 +95,7 @@ const fr = {
"dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "Aucun livre en cours", "dashboard.noCurrentlyReading": "Aucun livre en cours",
"dashboard.noRecentlyRead": "Aucun livre lu récemment", "dashboard.noRecentlyRead": "Aucun livre lu récemment",
"dashboard.allUsers": "Tous",
// Books page // Books page
"books.title": "Livres", "books.title": "Livres",
@@ -403,6 +405,21 @@ const fr = {
"tokens.revoked": "Révoqué", "tokens.revoked": "Révoqué",
"tokens.active": "Actif", "tokens.active": "Actif",
"tokens.revoke": "Révoquer", "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 page
"settings.title": "Paramètres", "settings.title": "Paramètres",

View File

@@ -0,0 +1 @@
ALTER TABLE komga_sync_reports ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE SET NULL;