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 */} +
+
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoFocus + required + disabled={loading} + placeholder="admin" + className=" + flex w-full h-11 px-4 + rounded-xl border border-input bg-background/60 + text-sm text-foreground + placeholder:text-muted-foreground/40 + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring + disabled:opacity-50 + transition-all duration-200 + " + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + disabled={loading} + placeholder="••••••••" + className=" + flex w-full h-11 px-4 + rounded-xl border border-input bg-background/60 + text-sm text-foreground + placeholder:text-muted-foreground/40 + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring + disabled:opacity-50 + transition-all duration-200 + " + /> +
+ + {error && ( +
+ + + + {error} +
+ )} + + +
+
+
+ ); +} + +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).*)", + ], +};