diff --git a/.env.example b/.env.example
index ac463c9..81311ba 100644
--- a/.env.example
+++ b/.env.example
@@ -13,6 +13,12 @@
# Use this token for the first API calls before creating proper API tokens
API_BOOTSTRAP_TOKEN=change-me-in-production
+# Backoffice admin credentials (required)
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=change-me-in-production
+# Secret for signing session JWTs (min 32 chars, required)
+SESSION_SECRET=change-me-in-production-use-32-chars-min
+
# =============================================================================
# Service Configuration
# =============================================================================
diff --git a/apps/backoffice/app/authors/[name]/page.tsx b/apps/backoffice/app/(app)/authors/[name]/page.tsx
similarity index 95%
rename from apps/backoffice/app/authors/[name]/page.tsx
rename to apps/backoffice/app/(app)/authors/[name]/page.tsx
index a41d4f5..987e6b0 100644
--- a/apps/backoffice/app/authors/[name]/page.tsx
+++ b/apps/backoffice/app/(app)/authors/[name]/page.tsx
@@ -1,7 +1,7 @@
-import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
-import { getServerTranslations } from "../../../lib/i18n/server";
-import { BooksGrid } from "../../components/BookCard";
-import { OffsetPagination } from "../../components/ui";
+import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
+import { getServerTranslations } from "@/lib/i18n/server";
+import { BooksGrid } from "@/app/components/BookCard";
+import { OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
diff --git a/apps/backoffice/app/authors/page.tsx b/apps/backoffice/app/(app)/authors/page.tsx
similarity index 95%
rename from apps/backoffice/app/authors/page.tsx
rename to apps/backoffice/app/(app)/authors/page.tsx
index ecbf26c..8e2a373 100644
--- a/apps/backoffice/app/authors/page.tsx
+++ b/apps/backoffice/app/(app)/authors/page.tsx
@@ -1,7 +1,7 @@
-import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
-import { getServerTranslations } from "../../lib/i18n/server";
-import { LiveSearchForm } from "../components/LiveSearchForm";
-import { Card, CardContent, OffsetPagination } from "../components/ui";
+import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
+import { getServerTranslations } from "@/lib/i18n/server";
+import { LiveSearchForm } from "@/app/components/LiveSearchForm";
+import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/books/[id]/page.tsx b/apps/backoffice/app/(app)/books/[id]/page.tsx
similarity index 95%
rename from apps/backoffice/app/books/[id]/page.tsx
rename to apps/backoffice/app/(app)/books/[id]/page.tsx
index 8178142..175f1f5 100644
--- a/apps/backoffice/app/books/[id]/page.tsx
+++ b/apps/backoffice/app/(app)/books/[id]/page.tsx
@@ -1,15 +1,15 @@
-import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
-import { BookPreview } from "../../components/BookPreview";
-import { ConvertButton } from "../../components/ConvertButton";
-import { MarkBookReadButton } from "../../components/MarkBookReadButton";
+import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
+import { BookPreview } from "@/app/components/BookPreview";
+import { ConvertButton } from "@/app/components/ConvertButton";
+import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
-import { SafeHtml } from "../../components/SafeHtml";
-import { getServerTranslations } from "../../../lib/i18n/server";
+import { SafeHtml } from "@/app/components/SafeHtml";
+import { getServerTranslations } from "@/lib/i18n/server";
import Image from "next/image";
import Link from "next/link";
const EditBookForm = nextDynamic(
- () => import("../../components/EditBookForm").then(m => m.EditBookForm)
+ () => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
);
import { notFound } from "next/navigation";
diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/(app)/books/page.tsx
similarity index 96%
rename from apps/backoffice/app/books/page.tsx
rename to apps/backoffice/app/(app)/books/page.tsx
index 4848fda..b866ba9 100644
--- a/apps/backoffice/app/books/page.tsx
+++ b/apps/backoffice/app/(app)/books/page.tsx
@@ -1,10 +1,10 @@
-import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
-import { BooksGrid, EmptyState } from "../components/BookCard";
-import { LiveSearchForm } from "../components/LiveSearchForm";
-import { Card, CardContent, OffsetPagination } from "../components/ui";
+import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
+import { BooksGrid, EmptyState } from "@/app/components/BookCard";
+import { LiveSearchForm } from "@/app/components/LiveSearchForm";
+import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link";
import Image from "next/image";
-import { getServerTranslations } from "../../lib/i18n/server";
+import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/jobs/[id]/page.tsx b/apps/backoffice/app/(app)/jobs/[id]/page.tsx
similarity index 99%
rename from apps/backoffice/app/jobs/[id]/page.tsx
rename to apps/backoffice/app/(app)/jobs/[id]/page.tsx
index 66f999c..051853d 100644
--- a/apps/backoffice/app/jobs/[id]/page.tsx
+++ b/apps/backoffice/app/(app)/jobs/[id]/page.tsx
@@ -2,13 +2,13 @@ export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import Link from "next/link";
-import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
+import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar
-} from "../../components/ui";
-import { JobDetailLive } from "../../components/JobDetailLive";
-import { getServerTranslations } from "../../../lib/i18n/server";
+} from "@/app/components/ui";
+import { JobDetailLive } from "@/app/components/JobDetailLive";
+import { getServerTranslations } from "@/lib/i18n/server";
interface JobDetailPageProps {
params: Promise<{ id: string }>;
diff --git a/apps/backoffice/app/jobs/page.tsx b/apps/backoffice/app/(app)/jobs/page.tsx
similarity index 98%
rename from apps/backoffice/app/jobs/page.tsx
rename to apps/backoffice/app/(app)/jobs/page.tsx
index 1c066d0..0256abf 100644
--- a/apps/backoffice/app/jobs/page.tsx
+++ b/apps/backoffice/app/(app)/jobs/page.tsx
@@ -1,9 +1,9 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
-import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
-import { JobsList } from "../components/JobsList";
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
-import { getServerTranslations } from "../../lib/i18n/server";
+import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "@/lib/api";
+import { JobsList } from "@/app/components/JobsList";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
+import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/(app)/layout.tsx b/apps/backoffice/app/(app)/layout.tsx
new file mode 100644
index 0000000..c657f63
--- /dev/null
+++ b/apps/backoffice/app/(app)/layout.tsx
@@ -0,0 +1,102 @@
+import Image from "next/image";
+import Link from "next/link";
+import type { ReactNode } from "react";
+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 { getServerTranslations } from "@/lib/i18n/server";
+import type { TranslationKey } from "@/lib/i18n/fr";
+
+type NavItem = {
+ href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
+ labelKey: TranslationKey;
+ icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
+};
+
+const navItems: NavItem[] = [
+ { href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
+ { href: "/books", labelKey: "nav.books", icon: "books" },
+ { href: "/series", labelKey: "nav.series", icon: "series" },
+ { href: "/authors", labelKey: "nav.authors", icon: "authors" },
+ { href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
+ { href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
+ { href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
+];
+
+export default async function AppLayout({ children }: { children: ReactNode }) {
+ const { t } = await getServerTranslations();
+
+ return (
+ <>
+
+
+
+ {children}
+
+ >
+ );
+}
+
+function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/backoffice/app/libraries/[id]/books/page.tsx b/apps/backoffice/app/(app)/libraries/[id]/books/page.tsx
similarity index 89%
rename from apps/backoffice/app/libraries/[id]/books/page.tsx
rename to apps/backoffice/app/(app)/libraries/[id]/books/page.tsx
index 90c54f4..f9919ac 100644
--- a/apps/backoffice/app/libraries/[id]/books/page.tsx
+++ b/apps/backoffice/app/(app)/libraries/[id]/books/page.tsx
@@ -1,9 +1,9 @@
-import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
-import { BooksGrid, EmptyState } from "../../../components/BookCard";
-import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
-import { OffsetPagination } from "../../../components/ui";
+import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
+import { BooksGrid, EmptyState } from "@/app/components/BookCard";
+import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
+import { OffsetPagination } from "@/app/components/ui";
import { notFound } from "next/navigation";
-import { getServerTranslations } from "../../../../lib/i18n/server";
+import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx
similarity index 91%
rename from apps/backoffice/app/libraries/[id]/series/[name]/page.tsx
rename to apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx
index 9e54459..50f2d7a 100644
--- a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx
+++ b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx
@@ -1,24 +1,24 @@
-import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
-import { BooksGrid, EmptyState } from "../../../../components/BookCard";
-import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
-import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
+import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
+import { BooksGrid, EmptyState } from "@/app/components/BookCard";
+import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
+import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic";
-import { OffsetPagination } from "../../../../components/ui";
-import { SafeHtml } from "../../../../components/SafeHtml";
+import { OffsetPagination } from "@/app/components/ui";
+import { SafeHtml } from "@/app/components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
const EditSeriesForm = nextDynamic(
- () => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm)
+ () => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
);
const MetadataSearchModal = nextDynamic(
- () => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal)
+ () => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
);
const ProwlarrSearchModal = nextDynamic(
- () => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
+ () => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
);
import { notFound } from "next/navigation";
-import { getServerTranslations } from "../../../../../lib/i18n/server";
+import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/libraries/[id]/series/page.tsx b/apps/backoffice/app/(app)/libraries/[id]/series/page.tsx
similarity index 93%
rename from apps/backoffice/app/libraries/[id]/series/page.tsx
rename to apps/backoffice/app/(app)/libraries/[id]/series/page.tsx
index ea811b1..eadfe56 100644
--- a/apps/backoffice/app/libraries/[id]/series/page.tsx
+++ b/apps/backoffice/app/(app)/libraries/[id]/series/page.tsx
@@ -1,12 +1,12 @@
-import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
-import { OffsetPagination } from "../../../components/ui";
-import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
-import { SeriesFilters } from "../../../components/SeriesFilters";
+import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
+import { OffsetPagination } from "@/app/components/ui";
+import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
+import { SeriesFilters } from "@/app/components/SeriesFilters";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
-import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
-import { getServerTranslations } from "../../../../lib/i18n/server";
+import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
+import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/libraries/page.tsx b/apps/backoffice/app/(app)/libraries/page.tsx
similarity index 96%
rename from apps/backoffice/app/libraries/page.tsx
rename to apps/backoffice/app/(app)/libraries/page.tsx
index 772c247..f746320 100644
--- a/apps/backoffice/app/libraries/page.tsx
+++ b/apps/backoffice/app/(app)/libraries/page.tsx
@@ -1,16 +1,16 @@
import { revalidatePath } from "next/cache";
import Image from "next/image";
import Link from "next/link";
-import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
-import type { TranslationKey } from "../../lib/i18n/fr";
-import { getServerTranslations } from "../../lib/i18n/server";
-import { LibraryActions } from "../components/LibraryActions";
-import { LibraryForm } from "../components/LibraryForm";
-import { ProviderIcon } from "../components/ProviderIcon";
+import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
+import type { TranslationKey } from "@/lib/i18n/fr";
+import { getServerTranslations } from "@/lib/i18n/server";
+import { LibraryActions } from "@/app/components/LibraryActions";
+import { LibraryForm } from "@/app/components/LibraryForm";
+import { ProviderIcon } from "@/app/components/ProviderIcon";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge
-} from "../components/ui";
+} from "@/app/components/ui";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/page.tsx b/apps/backoffice/app/(app)/page.tsx
similarity index 96%
rename from apps/backoffice/app/page.tsx
rename to apps/backoffice/app/(app)/page.tsx
index 1f3ee3b..cd70715 100644
--- a/apps/backoffice/app/page.tsx
+++ b/apps/backoffice/app/(app)/page.tsx
@@ -1,12 +1,12 @@
import React from "react";
-import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
-import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
-import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts";
-import { PeriodToggle } from "./components/PeriodToggle";
+import { fetchStats, StatsResponse, getBookCoverUrl } 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 Link from "next/link";
-import { getServerTranslations } from "../lib/i18n/server";
-import type { TranslateFunction } from "../lib/i18n/dictionaries";
+import { getServerTranslations } from "@/lib/i18n/server";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
export const dynamic = "force-dynamic";
@@ -88,7 +88,19 @@ export default async function DashboardPage({
);
}
- const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats;
+ const {
+ overview,
+ reading_status,
+ currently_reading = [],
+ recently_read = [],
+ reading_over_time = [],
+ by_format,
+ by_library,
+ top_series,
+ additions_over_time,
+ jobs_over_time = [],
+ metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
+ } = stats;
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [
diff --git a/apps/backoffice/app/series/page.tsx b/apps/backoffice/app/(app)/series/page.tsx
similarity index 95%
rename from apps/backoffice/app/series/page.tsx
rename to apps/backoffice/app/(app)/series/page.tsx
index 98751e0..417cd8b 100644
--- a/apps/backoffice/app/series/page.tsx
+++ b/apps/backoffice/app/(app)/series/page.tsx
@@ -1,11 +1,11 @@
-import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
-import { getServerTranslations } from "../../lib/i18n/server";
-import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
-import { LiveSearchForm } from "../components/LiveSearchForm";
-import { Card, CardContent, OffsetPagination } from "../components/ui";
+import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
+import { getServerTranslations } from "@/lib/i18n/server";
+import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
+import { LiveSearchForm } from "@/app/components/LiveSearchForm";
+import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Image from "next/image";
import Link from "next/link";
-import { ProviderIcon } from "../components/ProviderIcon";
+import { ProviderIcon } from "@/app/components/ProviderIcon";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/settings/SettingsPage.tsx b/apps/backoffice/app/(app)/settings/SettingsPage.tsx
similarity index 99%
rename from apps/backoffice/app/settings/SettingsPage.tsx
rename to apps/backoffice/app/(app)/settings/SettingsPage.tsx
index 534ab75..86cccf4 100644
--- a/apps/backoffice/app/settings/SettingsPage.tsx
+++ b/apps/backoffice/app/(app)/settings/SettingsPage.tsx
@@ -1,11 +1,11 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
-import { ProviderIcon } from "../components/ProviderIcon";
-import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
-import { useTranslation } from "../../lib/i18n/context";
-import type { Locale } from "../../lib/i18n/types";
+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 { useTranslation } from "@/lib/i18n/context";
+import type { Locale } from "@/lib/i18n/types";
interface SettingsPageProps {
initialSettings: Settings;
diff --git a/apps/backoffice/app/settings/page.tsx b/apps/backoffice/app/(app)/settings/page.tsx
similarity index 92%
rename from apps/backoffice/app/settings/page.tsx
rename to apps/backoffice/app/(app)/settings/page.tsx
index d339b21..ae41fbc 100644
--- a/apps/backoffice/app/settings/page.tsx
+++ b/apps/backoffice/app/(app)/settings/page.tsx
@@ -1,4 +1,4 @@
-import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
+import { getSettings, getCacheStats, getThumbnailStats } from "@/lib/api";
import SettingsPage from "./SettingsPage";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/tokens/page.tsx b/apps/backoffice/app/(app)/tokens/page.tsx
similarity index 98%
rename from apps/backoffice/app/tokens/page.tsx
rename to apps/backoffice/app/(app)/tokens/page.tsx
index c9c49dc..d440122 100644
--- a/apps/backoffice/app/tokens/page.tsx
+++ b/apps/backoffice/app/(app)/tokens/page.tsx
@@ -1,8 +1,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
-import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
-import { getServerTranslations } from "../../lib/i18n/server";
+import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "@/lib/api";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
+import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
diff --git a/apps/backoffice/app/api/auth/login/route.ts b/apps/backoffice/app/api/auth/login/route.ts
new file mode 100644
index 0000000..2817130
--- /dev/null
+++ b/apps/backoffice/app/api/auth/login/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from "next/server";
+import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
+
+export async function POST(req: NextRequest) {
+ const body = await req.json().catch(() => null);
+ if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
+ return NextResponse.json({ error: "Invalid request" }, { status: 400 });
+ }
+
+ const expectedUsername = process.env.ADMIN_USERNAME || "admin";
+ const expectedPassword = process.env.ADMIN_PASSWORD;
+
+ if (!expectedPassword) {
+ return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
+ }
+
+ if (body.username !== expectedUsername || body.password !== expectedPassword) {
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+ }
+
+ const token = await createSessionToken();
+ const response = NextResponse.json({ success: true });
+ response.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 7 * 24 * 60 * 60,
+ path: "/",
+ });
+ return response;
+}
diff --git a/apps/backoffice/app/api/auth/logout/route.ts b/apps/backoffice/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..1e80961
--- /dev/null
+++ b/apps/backoffice/app/api/auth/logout/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server";
+import { SESSION_COOKIE } from "@/lib/session";
+
+export async function POST() {
+ const response = NextResponse.json({ success: true });
+ response.cookies.delete(SESSION_COOKIE);
+ return response;
+}
diff --git a/apps/backoffice/app/components/LogoutButton.tsx b/apps/backoffice/app/components/LogoutButton.tsx
new file mode 100644
index 0000000..5544d07
--- /dev/null
+++ b/apps/backoffice/app/components/LogoutButton.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+export function LogoutButton() {
+ const router = useRouter();
+
+ async function handleLogout() {
+ await fetch("/api/auth/logout", { method: "POST" });
+ router.push("/login");
+ router.refresh();
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/backoffice/app/layout.tsx b/apps/backoffice/app/layout.tsx
index 91c7d8a..1b32910 100644
--- a/apps/backoffice/app/layout.tsx
+++ b/apps/backoffice/app/layout.tsx
@@ -1,130 +1,27 @@
import type { Metadata } from "next";
-import Image from "next/image";
-import Link from "next/link";
import type { ReactNode } from "react";
import "./globals.css";
import { ThemeProvider } from "./theme-provider";
-import { ThemeToggle } from "./theme-toggle";
-import { JobsIndicator } from "./components/JobsIndicator";
-import { NavIcon, Icon } from "./components/ui";
-import { MobileNav } from "./components/MobileNav";
-import { LocaleProvider } from "../lib/i18n/context";
-import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
-import type { TranslationKey } from "../lib/i18n/fr";
+import { LocaleProvider } from "@/lib/i18n/context";
+import { getServerLocale } from "@/lib/i18n/server";
export const metadata: Metadata = {
title: "StripStream Backoffice",
description: "Administration backoffice pour StripStream Librarian"
};
-type NavItem = {
- href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
- labelKey: TranslationKey;
- icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
-};
-
-const navItems: NavItem[] = [
- { href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
- { href: "/books", labelKey: "nav.books", icon: "books" },
- { href: "/series", labelKey: "nav.series", icon: "series" },
- { href: "/authors", labelKey: "nav.authors", icon: "authors" },
- { href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
- { href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
- { href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
-];
-
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getServerLocale();
- const { t } = await getServerTranslations();
return (
- {/* Header avec effet glassmorphism */}
-
-
- {/* Main Content */}
-
{children}
-
);
}
-
-// Navigation Link Component
-function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
diff --git a/apps/backoffice/app/login/page.tsx b/apps/backoffice/app/login/page.tsx
new file mode 100644
index 0000000..dc3b430
--- /dev/null
+++ b/apps/backoffice/app/login/page.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import Image from "next/image";
+import { useSearchParams } from "next/navigation";
+import { useState, Suspense } from "react";
+
+function LoginForm() {
+ const searchParams = useSearchParams();
+ const from = searchParams.get("from") || "/";
+
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ });
+
+ if (res.ok) {
+ window.location.href = from;
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setError(data.error || "Identifiants invalides");
+ }
+ } catch {
+ setError("Erreur réseau");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ {/* Background logo */}
+
+
+ {/* Hero */}
+
+
+ StripStream{" "}
+ : Librarian
+
+
+ Administration
+
+
+
+ {/* Form card */}
+
+
+ );
+}
+
+export default function LoginPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/backoffice/lib/session.ts b/apps/backoffice/lib/session.ts
new file mode 100644
index 0000000..4d0b1f6
--- /dev/null
+++ b/apps/backoffice/lib/session.ts
@@ -0,0 +1,33 @@
+import { SignJWT, jwtVerify } from "jose";
+import { cookies } from "next/headers";
+
+export const SESSION_COOKIE = "sl_session";
+
+function getSecret(): Uint8Array {
+ const secret = process.env.SESSION_SECRET;
+ if (!secret) throw new Error("SESSION_SECRET env var is required");
+ return new TextEncoder().encode(secret);
+}
+
+export async function createSessionToken(): Promise {
+ return new SignJWT({})
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("7d")
+ .sign(getSecret());
+}
+
+export async function verifySessionToken(token: string): Promise {
+ try {
+ await jwtVerify(token, getSecret());
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function getSession(): Promise {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(SESSION_COOKIE)?.value;
+ if (!token) return false;
+ return verifySessionToken(token);
+}
diff --git a/apps/backoffice/next-env.d.ts b/apps/backoffice/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/apps/backoffice/next-env.d.ts
+++ b/apps/backoffice/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/backoffice/package-lock.json b/apps/backoffice/package-lock.json
index bf841d8..37ced70 100644
--- a/apps/backoffice/package-lock.json
+++ b/apps/backoffice/package-lock.json
@@ -1,13 +1,14 @@
{
"name": "stripstream-backoffice",
- "version": "1.23.0",
+ "version": "1.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stripstream-backoffice",
- "version": "1.23.0",
+ "version": "1.28.0",
"dependencies": {
+ "jose": "^6.2.2",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",
@@ -143,9 +144,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -162,9 +160,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -181,9 +176,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -200,9 +192,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -219,9 +208,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -238,9 +224,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -257,9 +240,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -276,9 +256,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -295,9 +272,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -320,9 +294,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -345,9 +316,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -370,9 +338,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -395,9 +360,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -420,9 +382,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -445,9 +404,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -470,9 +426,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -659,9 +612,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -678,9 +628,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -697,9 +644,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -716,9 +660,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -950,9 +891,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -970,9 +908,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -990,9 +925,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1010,9 +942,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1179,6 +1108,7 @@
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1311,6 +1241,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1717,6 +1648,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -1860,9 +1800,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1884,9 +1821,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1908,9 +1842,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1932,9 +1863,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2147,6 +2075,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2168,6 +2097,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2177,6 +2107,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -2196,6 +2127,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -2248,7 +2180,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
diff --git a/apps/backoffice/package.json b/apps/backoffice/package.json
index 4891084..090e127 100644
--- a/apps/backoffice/package.json
+++ b/apps/backoffice/package.json
@@ -8,6 +8,7 @@
"start": "next start -p 7082"
},
"dependencies": {
+ "jose": "^6.2.2",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",
diff --git a/apps/backoffice/proxy.ts b/apps/backoffice/proxy.ts
new file mode 100644
index 0000000..aa44278
--- /dev/null
+++ b/apps/backoffice/proxy.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { jwtVerify } from "jose";
+import { SESSION_COOKIE } from "./lib/session";
+
+function getSecret(): Uint8Array {
+ const secret = process.env.SESSION_SECRET;
+ if (!secret) return new TextEncoder().encode("dev-insecure-secret");
+ return new TextEncoder().encode(secret);
+}
+
+export async function proxy(req: NextRequest) {
+ const { pathname } = req.nextUrl;
+
+ // Skip auth for login page and auth API routes
+ if (pathname.startsWith("/login") || pathname.startsWith("/api/auth")) {
+ return NextResponse.next();
+ }
+
+ const token = req.cookies.get(SESSION_COOKIE)?.value;
+ if (token) {
+ try {
+ await jwtVerify(token, getSecret());
+ return NextResponse.next();
+ } catch {
+ // Token invalid or expired
+ }
+ }
+
+ const loginUrl = new URL("/login", req.url);
+ loginUrl.searchParams.set("from", pathname);
+ return NextResponse.redirect(loginUrl);
+}
+
+export const config = {
+ matcher: [
+ "/((?!_next/static|_next/image|favicon\\.ico|logo\\.png|.*\\.svg).*)",
+ ],
+};