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};
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: uuid::Uuid,
}
#[derive(Clone, Debug)]
pub enum Scope {
Admin,
Read,
Read { user_id: uuid::Uuid },
}
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 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);
Ok(next.run(req).await)
}
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
let maybe_row = sqlx::query(
r#"
SELECT id, token_hash, scope
FROM api_tokens
SELECT id, token_hash, scope, user_id FROM api_tokens
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"))?;
match scope.as_str() {
"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")),
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
pub name: String,
#[schema(value_type = Option<String>, example = "read")]
pub scope: Option<String>,
#[schema(value_type = Option<String>)]
pub user_id: Option<Uuid>,
}
#[derive(Serialize, ToSchema)]
@@ -26,6 +28,9 @@ pub struct TokenResponse {
pub scope: String,
pub prefix: 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>>,
#[schema(value_type = Option<String>)]
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'")),
};
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];
OsRng.fill_bytes(&mut random);
let secret = URL_SAFE_NO_PAD.encode(random);
@@ -85,13 +94,14 @@ pub async fn create_token(
let id = Uuid::new_v4();
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(input.name.trim())
.bind(&prefix)
.bind(token_hash)
.bind(scope)
.bind(input.user_id)
.execute(&state.pool)
.await?;
@@ -118,7 +128,13 @@ pub async fn create_token(
)]
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
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)
.await?;
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
name: row.get("name"),
scope: row.get("scope"),
prefix: row.get("prefix"),
user_id: row.get("user_id"),
username: row.get("username"),
last_used_at: row.get("last_used_at"),
revoked_at: row.get("revoked_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})))
}
#[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
#[utoipa::path(
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 Link from "next/link";
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav";
import { UserSwitcher } from "@/app/components/UserSwitcher";
import { fetchUsers } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr";
@@ -27,6 +31,21 @@ const navItems: NavItem[] = [
export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations();
const cookieStore = await cookies();
const activeUserId = cookieStore.get("as_user_id")?.value || null;
const users = await fetchUsers().catch(() => []);
async function setActiveUserAction(formData: FormData) {
"use server";
const userId = formData.get("user_id") as string;
const store = await cookies();
if (userId) {
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
} else {
store.delete("as_user_id");
}
revalidatePath("/", "layout");
}
return (
<>
@@ -39,7 +58,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
{t("common.backoffice")}
</span>
</div>
@@ -50,16 +69,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
<UserSwitcher
users={users}
activeUserId={activeUserId}
setActiveUserAction={setActiveUserAction}
/>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />

View File

@@ -1,9 +1,9 @@
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 { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
import { PeriodToggle } from "@/app/components/PeriodToggle";
import Image from "next/image";
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
import Link from "next/link";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
@@ -70,8 +70,12 @@ export default async function DashboardPage({
const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null;
let users: UserDto[] = [];
try {
stats = await fetchStats(period);
[stats, users] = await Promise.all([
fetchStats(period),
fetchUsers().catch(() => []),
]);
} catch (e) {
console.error("Failed to fetch stats:", e);
}
@@ -94,6 +98,7 @@ export default async function DashboardPage({
currently_reading = [],
recently_read = [],
reading_over_time = [],
users_reading_over_time = [],
by_format,
by_library,
top_series,
@@ -145,37 +150,12 @@ export default async function DashboardPage({
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
</CardHeader>
<CardContent>
{currently_reading.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{currently_reading.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} 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>
)}
<CurrentlyReadingList
items={currently_reading}
allLabel={t("dashboard.allUsers")}
emptyLabel={t("dashboard.noCurrentlyReading")}
pageProgressTemplate={t("dashboard.pageProgress")}
/>
</CardContent>
</Card>
@@ -185,28 +165,11 @@ export default async function DashboardPage({
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
</CardHeader>
<CardContent>
{recently_read.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
) : (
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{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>
)}
<RecentlyReadList
items={recently_read}
allLabel={t("dashboard.allUsers")}
emptyLabel={t("dashboard.noRecentlyRead")}
/>
</CardContent>
</Card>
</div>
@@ -219,30 +182,84 @@ export default async function DashboardPage({
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcAreaChart
noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
color="hsl(142 60% 45%)"
/>
{(() => {
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
noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
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>
</Card>
{/* Charts row */}
<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}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
</CardHeader>
<CardContent>
<RcDonutChart
noDataLabel={noDataLabel}
data={[
{ name: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
{ name: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
]}
/>
{users.length === 0 ? (
<RcDonutChart
noDataLabel={noDataLabel}
data={[
{ name: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
{ name: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
{ 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>
</Card>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
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 type { Locale } from "@/lib/i18n/types";
@@ -11,9 +11,10 @@ interface SettingsPageProps {
initialSettings: Settings;
initialCacheStats: CacheStats;
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 [settings, setSettings] = useState<Settings>({
...initialSettings,
@@ -29,6 +30,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const [komgaUrl, setKomgaUrl] = useState("");
const [komgaUsername, setKomgaUsername] = useState("");
const [komgaPassword, setKomgaPassword] = useState("");
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
@@ -104,6 +106,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
if (data) {
if (data.url) setKomgaUrl(data.url);
if (data.username) setKomgaUsername(data.username);
if (data.user_id) setKomgaUserId(data.user_id);
}
}).catch(() => {});
}, [fetchReports]);
@@ -128,7 +131,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
const response = await fetch("/api/komga/sync", {
method: "POST",
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();
if (!response.ok) {
@@ -140,7 +143,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
fetch("/api/settings/komga", {
method: "POST",
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 {
@@ -627,9 +630,22 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</FormField>
</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
onClick={handleKomgaSync}
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
>
{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";
export const dynamic = "force-dynamic";
@@ -23,5 +23,7 @@ export default async function SettingsPageWrapper() {
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 { 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 { TokenUserSelect } from "@/app/components/TokenUserSelect";
import { UsernameEdit } from "@/app/components/UsernameEdit";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
@@ -14,13 +16,15 @@ export default async function TokensPage({
const { t } = await getServerTranslations();
const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]);
const users = await fetchUsers().catch(() => [] as UserDto[]);
async function createTokenAction(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const scope = formData.get("scope") as string;
const userId = (formData.get("user_id") as string) || undefined;
if (name) {
const result = await createToken(name, scope);
const result = await createToken(name, scope, userId);
revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
}
@@ -40,6 +44,40 @@ export default async function TokensPage({
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 (
<>
<div className="mb-6">
@@ -51,6 +89,115 @@ export default async function TokensPage({
</h1>
</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 ? (
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
@@ -72,7 +219,7 @@ export default async function TokensPage({
<form action={createTokenAction}>
<FormRow>
<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 className="w-32">
<FormSelect name="scope" defaultValue="read">
@@ -80,6 +227,14 @@ export default async function TokensPage({
<option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect>
</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>
</FormRow>
</form>
@@ -92,6 +247,7 @@ export default async function TokensPage({
<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("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.prefix")}</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) => (
<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">
<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">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{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;
prefix: string;
revoked_at: string | null;
user_id?: string;
username?: string;
};
export type UserDto = {
id: string;
username: string;
token_count: number;
books_read: number;
books_reading: number;
created_at: string;
};
export type FolderItem = {
@@ -151,6 +162,16 @@ export async function apiFetch<T>(
headers.set("Content-Type", "application/json");
}
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
try {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const asUserId = cookieStore.get("as_user_id")?.value;
if (asUserId) headers.set("X-As-User", asUserId);
} catch {
// Hors contexte Next.js (tests, etc.)
}
const { next: nextOptions, ...restInit } = init ?? {};
const res = await fetch(`${baseUrl}${path}`, {
@@ -268,10 +289,32 @@ export async function listTokens() {
return apiFetch<TokenDto[]>("/admin/tokens");
}
export async function createToken(name: string, scope: string) {
export async function createToken(name: string, scope: string, userId?: string) {
return apiFetch<{ token: string }>("/admin/tokens", {
method: "POST",
body: JSON.stringify({ name, scope }),
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
});
}
export async function fetchUsers(): Promise<UserDto[]> {
return apiFetch<UserDto[]>("/admin/users");
}
export async function createUser(username: string): Promise<UserDto> {
return apiFetch<UserDto>("/admin/users", {
method: "POST",
body: JSON.stringify({ username }),
});
}
export async function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
}
export async function updateUser(id: string, username: string): Promise<void> {
return apiFetch<void>(`/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ username }),
});
}
@@ -283,6 +326,13 @@ export async function deleteToken(id: string) {
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
}
export async function updateToken(id: string, userId: string | null) {
return apiFetch<void>(`/admin/tokens/${id}`, {
method: "PATCH",
body: JSON.stringify({ user_id: userId || null }),
});
}
export async function fetchBooks(
libraryId?: string,
series?: string,
@@ -557,6 +607,7 @@ export type CurrentlyReadingItem = {
series: string | null;
current_page: number;
page_count: number;
username?: string;
};
export type RecentlyReadItem = {
@@ -564,6 +615,7 @@ export type RecentlyReadItem = {
title: string;
series: string | null;
last_read_at: string;
username?: string;
};
export type MonthlyReading = {
@@ -571,6 +623,12 @@ export type MonthlyReading = {
books_read: number;
};
export type UserMonthlyReading = {
month: string;
username: string;
books_read: number;
};
export type JobTimePoint = {
label: string;
scan: number;
@@ -585,6 +643,7 @@ export type StatsResponse = {
currently_reading: CurrentlyReadingItem[];
recently_read: RecentlyReadItem[];
reading_over_time: MonthlyReading[];
users_reading_over_time: UserMonthlyReading[];
by_format: FormatCount[];
by_language: LanguageCount[];
by_library: LibraryStatsItem[];
@@ -699,11 +758,13 @@ export type KomgaSyncRequest = {
url: string;
username: string;
password: string;
user_id: string;
};
export type KomgaSyncResponse = {
id: string;
komga_url: string;
user_id?: string;
total_komga_read: number;
matched: number;
already_read: number;
@@ -717,6 +778,7 @@ export type KomgaSyncResponse = {
export type KomgaSyncReportSummary = {
id: string;
komga_url: string;
user_id?: string;
total_komga_read: number;
matched: number;
already_read: number;

View File

@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
"nav.libraries": "Libraries",
"nav.jobs": "Jobs",
"nav.tokens": "Tokens",
"nav.users": "Users",
"nav.settings": "Settings",
"nav.navigation": "Navigation",
"nav.closeMenu": "Close menu",
@@ -96,6 +97,7 @@ const en: Record<TranslationKey, string> = {
"dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "No books in progress",
"dashboard.noRecentlyRead": "No books read recently",
"dashboard.allUsers": "All",
// Books page
"books.title": "Books",
@@ -405,6 +407,21 @@ const en: Record<TranslationKey, string> = {
"tokens.revoked": "Revoked",
"tokens.active": "Active",
"tokens.revoke": "Revoke",
"tokens.user": "User",
"tokens.noUser": "None (admin)",
"tokens.apiTokens": "API Tokens",
// Users page
"users.title": "Users",
"users.createNew": "Create a user",
"users.createDescription": "Create a user account for read access",
"users.username": "Username",
"users.createButton": "Create",
"users.name": "Username",
"users.tokenCount": "Tokens",
"users.createdAt": "Created",
"users.actions": "Actions",
"users.noUsers": "No users",
// Settings page
"settings.title": "Settings",

View File

@@ -6,6 +6,7 @@ const fr = {
"nav.libraries": "Bibliothèques",
"nav.jobs": "Tâches",
"nav.tokens": "Jetons",
"nav.users": "Utilisateurs",
"nav.settings": "Paramètres",
"nav.navigation": "Navigation",
"nav.closeMenu": "Fermer le menu",
@@ -94,6 +95,7 @@ const fr = {
"dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "Aucun livre en cours",
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
"dashboard.allUsers": "Tous",
// Books page
"books.title": "Livres",
@@ -403,6 +405,21 @@ const fr = {
"tokens.revoked": "Révoqué",
"tokens.active": "Actif",
"tokens.revoke": "Révoquer",
"tokens.user": "Utilisateur",
"tokens.noUser": "Aucun (admin)",
"tokens.apiTokens": "Tokens API",
// Users page
"users.title": "Utilisateurs",
"users.createNew": "Créer un utilisateur",
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
"users.username": "Nom d'utilisateur",
"users.createButton": "Créer",
"users.name": "Nom d'utilisateur",
"users.tokenCount": "Nb de jetons",
"users.createdAt": "Créé le",
"users.actions": "Actions",
"users.noUsers": "Aucun utilisateur",
// Settings page
"settings.title": "Paramètres",

View File

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