feat: add backoffice authentication with login page
- Add login page with logo background, glassmorphism card - Add session management via JWT (jose) with httpOnly cookie - Add Next.js proxy middleware to protect all routes - Add logout button in nav - Restructure app into (app) route group to isolate login layout - Add ADMIN_USERNAME, ADMIN_PASSWORD, SESSION_SECRET env vars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }>;
|
||||
@@ -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";
|
||||
|
||||
102
apps/backoffice/app/(app)/layout.tsx
Normal file
102
apps/backoffice/app/(app)/layout.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||
>
|
||||
<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">
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{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>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<LogoutButton />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = [
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
31
apps/backoffice/app/api/auth/login/route.ts
Normal file
31
apps/backoffice/app/api/auth/login/route.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
apps/backoffice/app/api/auth/logout/route.ts
Normal file
8
apps/backoffice/app/api/auth/logout/route.ts
Normal file
@@ -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;
|
||||
}
|
||||
27
apps/backoffice/app/components/LogoutButton.tsx
Normal file
27
apps/backoffice/app/components/LogoutButton.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Se déconnecter"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||
<ThemeProvider>
|
||||
<LocaleProvider initialLocale={locale}>
|
||||
{/* Header avec effet glassmorphism */}
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||
>
|
||||
<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">
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{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>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<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"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</LocaleProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation Link Component
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
168
apps/backoffice/app/login/page.tsx
Normal file
168
apps/backoffice/app/login/page.tsx
Normal file
@@ -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 (
|
||||
<div className="relative min-h-screen flex flex-col items-center justify-center px-4 py-16 overflow-hidden">
|
||||
|
||||
{/* Background logo */}
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover opacity-20"
|
||||
priority
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative flex flex-col items-center mb-10">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||
StripStream{" "}
|
||||
<span className="text-primary font-light">: Librarian</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 tracking-wide uppercase font-medium">
|
||||
Administration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-2xl border border-white/20 backdrop-blur-sm p-8"
|
||||
style={{ boxShadow: "0 24px 48px -12px rgb(0 0 0 / 0.18)" }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Identifiant
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-sm text-destructive">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="
|
||||
w-full h-11 mt-2
|
||||
inline-flex items-center justify-center gap-2
|
||||
rounded-xl font-medium text-sm
|
||||
bg-primary text-primary-foreground
|
||||
hover:bg-primary/90
|
||||
transition-all duration-200 ease-out
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
active:scale-[0.98]
|
||||
"
|
||||
style={{ boxShadow: "0 4px 16px -4px hsl(198 78% 37% / 0.5)" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Connexion…
|
||||
</>
|
||||
) : "Se connecter"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user