diff --git a/package.json b/package.json index d07eee4..d4c848f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@prisma/client": "^6.17.1", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "1.0.5", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-progress": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e15e99d..c6cc133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@prisma/client': specifier: ^6.17.1 version: 6.17.1(prisma@6.17.1(typescript@5.3.3))(typescript@5.3.3) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dialog': specifier: 1.0.5 version: 1.0.5(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -578,6 +584,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -591,6 +610,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.0.3': resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: @@ -666,6 +698,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -3483,6 +3528,20 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.64)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.64 + '@types/react-dom': 18.2.21 + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -3492,6 +3551,22 @@ snapshots: '@types/react': 18.2.64 '@types/react-dom': 18.2.21 + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.2.64)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.64 + '@types/react-dom': 18.2.21 + '@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.28.4 @@ -3566,6 +3641,28 @@ snapshots: '@types/react': 18.2.64 '@types/react-dom': 18.2.21 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.64)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.64)(react@18.2.0) + aria-hidden: 1.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.7.1(@types/react@18.2.64)(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.64 + '@types/react-dom': 18.2.21 + '@radix-ui/react-direction@1.1.1(@types/react@18.2.64)(react@18.2.0)': dependencies: react: 18.2.0 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..da759e5 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,24 @@ +import { AdminService } from "@/lib/services/admin.service"; +import { redirect } from "next/navigation"; +import { isAdmin } from "@/lib/auth-utils"; +import { AdminContent } from "@/components/admin/AdminContent"; + +export default async function AdminPage() { + try { + const hasAdminAccess = await isAdmin(); + + if (!hasAdminAccess) { + redirect("/"); + } + + const [users, stats] = await Promise.all([ + AdminService.getAllUsers(), + AdminService.getUserStats(), + ]); + + return ; + } catch (error) { + console.error("Erreur lors du chargement de la page admin:", error); + redirect("/"); + } +} diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..2f3cff6 --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { AdminService } from "@/lib/services/admin.service"; +import { AppError } from "@/utils/errors"; + +export async function GET() { + try { + const stats = await AdminService.getUserStats(); + return NextResponse.json(stats); + } catch (error) { + console.error("Erreur lors de la récupération des stats:", error); + + if (error instanceof AppError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { + status: error.code === "AUTH_FORBIDDEN" ? 403 : + error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500 + } + ); + } + + return NextResponse.json( + { error: "Erreur lors de la récupération des stats" }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/admin/users/[userId]/password/route.ts b/src/app/api/admin/users/[userId]/password/route.ts new file mode 100644 index 0000000..5aa56eb --- /dev/null +++ b/src/app/api/admin/users/[userId]/password/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AdminService } from "@/lib/services/admin.service"; +import { AppError } from "@/utils/errors"; +import { AuthServerService } from "@/lib/services/auth-server.service"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const { userId } = await params; + const body = await request.json(); + const { newPassword } = body; + + if (!newPassword) { + return NextResponse.json( + { error: "Nouveau mot de passe manquant" }, + { status: 400 } + ); + } + + // Vérifier que le mot de passe est fort + if (!AuthServerService.isPasswordStrong(newPassword)) { + return NextResponse.json( + { + error: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre" + }, + { status: 400 } + ); + } + + await AdminService.resetUserPassword(userId, newPassword); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erreur lors de la réinitialisation du mot de passe:", error); + + if (error instanceof AppError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { + status: error.code === "AUTH_FORBIDDEN" ? 403 : + error.code === "AUTH_UNAUTHENTICATED" ? 401 : + error.code === "AUTH_USER_NOT_FOUND" ? 404 : + error.code === "ADMIN_CANNOT_RESET_OWN_PASSWORD" ? 400 : 500 + } + ); + } + + return NextResponse.json( + { error: "Erreur lors de la réinitialisation du mot de passe" }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/admin/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts new file mode 100644 index 0000000..ba373b1 --- /dev/null +++ b/src/app/api/admin/users/[userId]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AdminService } from "@/lib/services/admin.service"; +import { AppError } from "@/utils/errors"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const { userId } = await params; + const body = await request.json(); + const { roles } = body; + + if (!roles || !Array.isArray(roles)) { + return NextResponse.json( + { error: "Rôles invalides" }, + { status: 400 } + ); + } + + await AdminService.updateUserRoles(userId, roles); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erreur lors de la mise à jour de l'utilisateur:", error); + + if (error instanceof AppError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { + status: error.code === "AUTH_FORBIDDEN" ? 403 : + error.code === "AUTH_UNAUTHENTICATED" ? 401 : + error.code === "AUTH_USER_NOT_FOUND" ? 404 : 500 + } + ); + } + + return NextResponse.json( + { error: "Erreur lors de la mise à jour de l'utilisateur" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const { userId } = await params; + await AdminService.deleteUser(userId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erreur lors de la suppression de l'utilisateur:", error); + + if (error instanceof AppError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { + status: error.code === "AUTH_FORBIDDEN" ? 403 : + error.code === "AUTH_UNAUTHENTICATED" ? 401 : + error.code === "AUTH_USER_NOT_FOUND" ? 404 : + error.code === "ADMIN_CANNOT_DELETE_SELF" ? 400 : 500 + } + ); + } + + return NextResponse.json( + { error: "Erreur lors de la suppression de l'utilisateur" }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..2dcd849 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { AdminService } from "@/lib/services/admin.service"; +import { AppError } from "@/utils/errors"; + +export async function GET() { + try { + const users = await AdminService.getAllUsers(); + return NextResponse.json(users); + } catch (error) { + console.error("Erreur lors de la récupération des utilisateurs:", error); + + if (error instanceof AppError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { + status: error.code === "AUTH_FORBIDDEN" ? 403 : + error.code === "AUTH_UNAUTHENTICATED" ? 401 : 500 + } + ); + } + + return NextResponse.json( + { error: "Erreur lors de la récupération des utilisateurs" }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4a79765..9cfe20e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -74,14 +74,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo let libraries: KomgaLibrary[] = []; let favorites: KomgaSeries[] = []; let preferences: UserPreferences = defaultPreferences; + let userIsAdmin = false; try { // Tentative de chargement des données. Si l'utilisateur n'est pas authentifié, // les services lanceront une erreur mais l'application continuera de fonctionner - const [librariesData, favoritesData, preferencesData] = await Promise.allSettled([ + const [librariesData, favoritesData, preferencesData, isAdminCheck] = await Promise.allSettled([ LibraryService.getLibraries(), FavoriteService.getAllFavoriteIds(), PreferencesService.getPreferences(), + import("@/lib/auth-utils").then((m) => m.isAdmin()), ]); if (librariesData.status === "fulfilled") { @@ -95,6 +97,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo if (preferencesData.status === "fulfilled") { preferences = preferencesData.value; } + + if (isAdminCheck.status === "fulfilled") { + userIsAdmin = isAdminCheck.value; + } } catch (error) { console.error("Erreur lors du chargement des données de la sidebar:", error); } @@ -162,7 +168,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo - + {children} diff --git a/src/components/admin/AdminContent.tsx b/src/components/admin/AdminContent.tsx new file mode 100644 index 0000000..e55c3c0 --- /dev/null +++ b/src/components/admin/AdminContent.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { AdminUserData } from "@/lib/services/admin.service"; +import { StatsCards } from "./StatsCards"; +import { UsersTable } from "./UsersTable"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; + +interface AdminContentProps { + initialUsers: AdminUserData[]; + initialStats: { + totalUsers: number; + totalAdmins: number; + usersWithKomga: number; + usersWithPreferences: number; + }; +} + +export function AdminContent({ initialUsers, initialStats }: AdminContentProps) { + const [users, setUsers] = useState(initialUsers); + const [stats, setStats] = useState(initialStats); + const [isRefreshing, setIsRefreshing] = useState(false); + const { toast } = useToast(); + + const refreshData = useCallback(async () => { + setIsRefreshing(true); + try { + const [usersResponse, statsResponse] = await Promise.all([ + fetch("/api/admin/users"), + fetch("/api/admin/stats"), + ]); + + if (!usersResponse.ok || !statsResponse.ok) { + throw new Error("Erreur lors du rafraîchissement"); + } + + const [newUsers, newStats] = await Promise.all([ + usersResponse.json(), + statsResponse.json(), + ]); + + setUsers(newUsers); + setStats(newStats); + + toast({ + title: "Données rafraîchies", + description: "Les données ont été mises à jour", + }); + } catch { + toast({ + variant: "destructive", + title: "Erreur", + description: "Impossible de rafraîchir les données", + }); + } finally { + setIsRefreshing(false); + } + }, [toast]); + + return ( +
+
+
+
+

Administration

+

+ Gérez les utilisateurs de la plateforme +

+
+ +
+ + + +
+

Utilisateurs

+ +
+
+
+ ); +} + diff --git a/src/components/admin/DeleteUserDialog.tsx b/src/components/admin/DeleteUserDialog.tsx new file mode 100644 index 0000000..4d0374a --- /dev/null +++ b/src/components/admin/DeleteUserDialog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useToast } from "@/components/ui/use-toast"; +import type { AdminUserData } from "@/lib/services/admin.service"; + +interface DeleteUserDialogProps { + user: AdminUserData; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +export function DeleteUserDialog({ + user, + open, + onOpenChange, + onSuccess, +}: DeleteUserDialogProps) { + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleDelete = async () => { + setIsLoading(true); + + try { + const response = await fetch(`/api/admin/users/${user.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Erreur lors de la suppression"); + } + + toast({ + title: "Succès", + description: "L'utilisateur a été supprimé", + }); + + onSuccess(); + } catch (error) { + toast({ + variant: "destructive", + title: "Erreur", + description: error instanceof Error ? error.message : "Une erreur est survenue", + }); + setIsLoading(false); + } + }; + + return ( + + + + Êtes-vous sûr? + + Vous allez supprimer l'utilisateur {user.email}. +
+ Cette action est irréversible et supprimera également : +
    +
  • Sa configuration Komga
  • +
  • Ses préférences
  • +
  • Ses favoris ({user._count?.favorites || 0})
  • +
+
+
+ + Annuler + + {isLoading ? "Suppression..." : "Supprimer"} + + +
+
+ ); +} + diff --git a/src/components/admin/EditUserDialog.tsx b/src/components/admin/EditUserDialog.tsx new file mode 100644 index 0000000..b1b5cd4 --- /dev/null +++ b/src/components/admin/EditUserDialog.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/components/ui/use-toast"; +import type { AdminUserData } from "@/lib/services/admin.service"; + +interface EditUserDialogProps { + user: AdminUserData; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +const AVAILABLE_ROLES = [ + { value: "ROLE_USER", label: "User" }, + { value: "ROLE_ADMIN", label: "Admin" }, +]; + +export function EditUserDialog({ + user, + open, + onOpenChange, + onSuccess, +}: EditUserDialogProps) { + const [selectedRoles, setSelectedRoles] = useState(user.roles); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleRoleToggle = (role: string) => { + setSelectedRoles((prev) => + prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role] + ); + }; + + const handleSubmit = async () => { + if (selectedRoles.length === 0) { + toast({ + variant: "destructive", + title: "Erreur", + description: "L'utilisateur doit avoir au moins un rôle", + }); + return; + } + + setIsLoading(true); + + try { + const response = await fetch(`/api/admin/users/${user.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roles: selectedRoles }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Erreur lors de la mise à jour"); + } + + toast({ + title: "Succès", + description: "Les rôles ont été mis à jour", + }); + + onSuccess(); + } catch (error) { + toast({ + variant: "destructive", + title: "Erreur", + description: error instanceof Error ? error.message : "Une erreur est survenue", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Modifier l'utilisateur + + Gérer les rôles de {user.email} + + + +
+
+ + {AVAILABLE_ROLES.map((role) => ( +
+ handleRoleToggle(role.value)} + disabled={isLoading} + /> + +
+ ))} +
+
+ + + + + +
+
+ ); +} + diff --git a/src/components/admin/ResetPasswordDialog.tsx b/src/components/admin/ResetPasswordDialog.tsx new file mode 100644 index 0000000..586954e --- /dev/null +++ b/src/components/admin/ResetPasswordDialog.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/components/ui/use-toast"; +import { Lock } from "lucide-react"; +import type { AdminUserData } from "@/lib/services/admin.service"; + +interface ResetPasswordDialogProps { + user: AdminUserData; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +export function ResetPasswordDialog({ + user, + open, + onOpenChange, + onSuccess, +}: ResetPasswordDialogProps) { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleSubmit = async () => { + if (!newPassword || !confirmPassword) { + toast({ + variant: "destructive", + title: "Erreur", + description: "Veuillez remplir tous les champs", + }); + return; + } + + if (newPassword !== confirmPassword) { + toast({ + variant: "destructive", + title: "Erreur", + description: "Les mots de passe ne correspondent pas", + }); + return; + } + + if (newPassword.length < 8) { + toast({ + variant: "destructive", + title: "Erreur", + description: "Le mot de passe doit contenir au moins 8 caractères", + }); + return; + } + + setIsLoading(true); + + try { + const response = await fetch(`/api/admin/users/${user.id}/password`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPassword }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Erreur lors de la réinitialisation"); + } + + toast({ + title: "Succès", + description: "Le mot de passe a été réinitialisé", + }); + + setNewPassword(""); + setConfirmPassword(""); + onSuccess(); + } catch (error) { + toast({ + variant: "destructive", + title: "Erreur", + description: error instanceof Error ? error.message : "Une erreur est survenue", + }); + } finally { + setIsLoading(false); + } + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + setNewPassword(""); + setConfirmPassword(""); + } + onOpenChange(open); + }; + + return ( + + + + Réinitialiser le mot de passe + + Définir un nouveau mot de passe pour {user.email} + + + +
+
+ +
+ + setNewPassword(e.target.value)} + className="pl-9" + placeholder="Minimum 8 caractères" + disabled={isLoading} + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-9" + placeholder="Confirmer le nouveau mot de passe" + disabled={isLoading} + /> +
+
+ +

+ Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre. +

+
+ + + + + +
+
+ ); +} + diff --git a/src/components/admin/StatsCards.tsx b/src/components/admin/StatsCards.tsx new file mode 100644 index 0000000..278f134 --- /dev/null +++ b/src/components/admin/StatsCards.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Users, Shield, Settings, Bookmark } from "lucide-react"; + +interface StatsCardsProps { + stats: { + totalUsers: number; + totalAdmins: number; + usersWithKomga: number; + usersWithPreferences: number; + }; +} + +export function StatsCards({ stats }: StatsCardsProps) { + const cards = [ + { + title: "Utilisateurs totaux", + value: stats.totalUsers, + icon: Users, + description: "Comptes enregistrés", + }, + { + title: "Administrateurs", + value: stats.totalAdmins, + icon: Shield, + description: "Avec privilèges admin", + }, + { + title: "Config Komga", + value: stats.usersWithKomga, + icon: Bookmark, + description: "Utilisateurs configurés", + }, + { + title: "Préférences", + value: stats.usersWithPreferences, + icon: Settings, + description: "Préférences personnalisées", + }, + ]; + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( + + + {card.title} + + + +
{card.value}
+

{card.description}

+
+
+ ); + })} +
+ ); +} + diff --git a/src/components/admin/UsersTable.tsx b/src/components/admin/UsersTable.tsx new file mode 100644 index 0000000..7b2f4c0 --- /dev/null +++ b/src/components/admin/UsersTable.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Pencil, Trash2, Check, X, KeyRound } from "lucide-react"; +import type { AdminUserData } from "@/lib/services/admin.service"; +import { EditUserDialog } from "./EditUserDialog"; +import { DeleteUserDialog } from "./DeleteUserDialog"; +import { ResetPasswordDialog } from "./ResetPasswordDialog"; + +interface UsersTableProps { + users: AdminUserData[]; + onUserUpdated: () => void; +} + +export function UsersTable({ users, onUserUpdated }: UsersTableProps) { + const [editingUser, setEditingUser] = useState(null); + const [deletingUser, setDeletingUser] = useState(null); + const [resettingPasswordUser, setResettingPasswordUser] = useState(null); + + return ( + <> +
+ + + + Email + Rôles + Config Komga + Préférences + Favoris + Créé le + Actions + + + + {users.length === 0 ? ( + + + Aucun utilisateur + + + ) : ( + users.map((user) => ( + + {user.email} + +
+ {user.roles.map((role) => ( + + {role.replace("ROLE_", "")} + + ))} +
+
+ + {user.hasKomgaConfig ? ( +
+ + Configuré +
+ ) : ( +
+ + Non configuré +
+ )} +
+ + {user.hasPreferences ? ( +
+ + Oui +
+ ) : ( +
+ + Non +
+ )} +
+ {user._count?.favorites || 0} + + {new Date(user.createdAt).toLocaleDateString("fr-FR")} + + +
+ + + +
+
+
+ )) + )} +
+
+
+ + {editingUser && ( + !open && setEditingUser(null)} + onSuccess={() => { + setEditingUser(null); + onUserUpdated(); + }} + /> + )} + + {resettingPasswordUser && ( + !open && setResettingPasswordUser(null)} + onSuccess={() => { + setResettingPasswordUser(null); + }} + /> + )} + + {deletingUser && ( + !open && setDeletingUser(null)} + onSuccess={() => { + setDeletingUser(null); + onUserUpdated(); + }} + /> + )} + + ); +} + diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 2b71c2b..06aae95 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -20,9 +20,10 @@ interface ClientLayoutProps { children: React.ReactNode; initialLibraries: KomgaLibrary[]; initialFavorites: KomgaSeries[]; + userIsAdmin?: boolean; } -export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [] }: ClientLayoutProps) { +export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [], userIsAdmin = false }: ClientLayoutProps) { const [isSidebarOpen, setIsSidebarOpen] = useState(false); const pathname = usePathname(); @@ -77,7 +78,8 @@ export default function ClientLayout({ children, initialLibraries = [], initialF isOpen={isSidebarOpen} onClose={handleCloseSidebar} initialLibraries={initialLibraries} - initialFavorites={initialFavorites} + initialFavorites={initialFavorites} + userIsAdmin={userIsAdmin} /> )}
{children}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e6296d6..ef35941 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { Home, Library, Settings, LogOut, RefreshCw, Star, Download, User } from "lucide-react"; +import { Home, Library, Settings, LogOut, RefreshCw, Star, Download, User, Shield } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; import { signOut } from "next-auth/react"; @@ -18,9 +18,10 @@ interface SidebarProps { onClose: () => void; initialLibraries: KomgaLibrary[]; initialFavorites: KomgaSeries[]; + userIsAdmin?: boolean; } -export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites }: SidebarProps) { +export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, userIsAdmin = false }: SidebarProps) { const { t } = useTranslate(); const pathname = usePathname(); const router = useRouter(); @@ -294,6 +295,18 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites }: {t("sidebar.settings.preferences")} + {userIsAdmin && ( + + )} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..6ceecf7 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,117 @@ +"use client"; + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..e1d542b --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; + diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b7d4880 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,104 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..a9d9298 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; + diff --git a/src/constants/errorCodes.ts b/src/constants/errorCodes.ts index f8dc09c..4f574f6 100644 --- a/src/constants/errorCodes.ts +++ b/src/constants/errorCodes.ts @@ -18,6 +18,7 @@ export const ERROR_CODES = { INVALID_PASSWORD: "AUTH_INVALID_PASSWORD", PASSWORD_CHANGE_ERROR: "AUTH_PASSWORD_CHANGE_ERROR", FETCH_ERROR: "AUTH_FETCH_ERROR", + FORBIDDEN: "AUTH_FORBIDDEN", }, KOMGA: { MISSING_CONFIG: "KOMGA_MISSING_CONFIG", @@ -95,6 +96,15 @@ export const ERROR_CODES = { NETWORK_ERROR: "CLIENT_NETWORK_ERROR", REQUEST_FAILED: "CLIENT_REQUEST_FAILED", }, + ADMIN: { + FETCH_USERS_ERROR: "ADMIN_FETCH_USERS_ERROR", + UPDATE_USER_ERROR: "ADMIN_UPDATE_USER_ERROR", + DELETE_USER_ERROR: "ADMIN_DELETE_USER_ERROR", + FETCH_STATS_ERROR: "ADMIN_FETCH_STATS_ERROR", + CANNOT_DELETE_SELF: "ADMIN_CANNOT_DELETE_SELF", + RESET_PASSWORD_ERROR: "ADMIN_RESET_PASSWORD_ERROR", + CANNOT_RESET_OWN_PASSWORD: "ADMIN_CANNOT_RESET_OWN_PASSWORD", + }, } as const; type Values = T[keyof T]; diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 45c6330..efbb445 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -62,6 +62,7 @@ "title": "Settings", "preferences": "Preferences" }, + "admin": "Administration", "logout": "Sign out" }, "settings": { diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index b3bc737..d9af89d 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -62,6 +62,7 @@ "title": "Configuration", "preferences": "Préférences" }, + "admin": "Administration", "logout": "Se déconnecter" }, "settings": { diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts index ecb311f..2e51560 100644 --- a/src/lib/auth-utils.ts +++ b/src/lib/auth-utils.ts @@ -15,3 +15,22 @@ export async function getCurrentUser(): Promise { authenticated: true, }; } + +export async function isAdmin(): Promise { + const user = await getCurrentUser(); + return user?.roles.includes("ROLE_ADMIN") ?? false; +} + +export async function requireAdmin(): Promise { + const user = await getCurrentUser(); + + if (!user) { + throw new Error("Unauthenticated"); + } + + if (!user.roles.includes("ROLE_ADMIN")) { + throw new Error("Forbidden: Admin access required"); + } + + return user; +} diff --git a/src/lib/services/admin.service.ts b/src/lib/services/admin.service.ts new file mode 100644 index 0000000..9d9fd9b --- /dev/null +++ b/src/lib/services/admin.service.ts @@ -0,0 +1,208 @@ +import prisma from "@/lib/prisma"; +import { requireAdmin } from "../auth-utils"; +import { ERROR_CODES } from "../../constants/errorCodes"; +import { AppError } from "../../utils/errors"; + +export interface AdminUserData { + id: string; + email: string; + roles: string[]; + createdAt: Date; + updatedAt: Date; + _count?: { + favorites: number; + }; + hasKomgaConfig: boolean; + hasPreferences: boolean; +} + +export class AdminService { + static async getAllUsers(): Promise { + try { + await requireAdmin(); + + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + roles: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + favorites: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Vérifier les configs pour chaque user + const usersWithConfigs = await Promise.all( + users.map(async (user) => { + const [komgaConfig, preferences] = await Promise.all([ + prisma.komgaConfig.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }), + prisma.preferences.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }), + ]); + + return { + ...user, + hasKomgaConfig: !!komgaConfig, + hasPreferences: !!preferences, + }; + }) + ); + + return usersWithConfigs; + } catch (error) { + if (error instanceof Error && error.message.includes("Forbidden")) { + throw new AppError(ERROR_CODES.AUTH.FORBIDDEN); + } + if (error instanceof AppError) { + throw error; + } + throw new AppError(ERROR_CODES.ADMIN.FETCH_USERS_ERROR, {}, error); + } + } + + static async updateUserRoles(userId: string, roles: string[]): Promise { + try { + await requireAdmin(); + + // Vérifier que l'utilisateur existe + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new AppError(ERROR_CODES.AUTH.USER_NOT_FOUND); + } + + // Mettre à jour les rôles + await prisma.user.update({ + where: { id: userId }, + data: { roles }, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("Forbidden")) { + throw new AppError(ERROR_CODES.AUTH.FORBIDDEN); + } + if (error instanceof AppError) { + throw error; + } + throw new AppError(ERROR_CODES.ADMIN.UPDATE_USER_ERROR, {}, error); + } + } + + static async deleteUser(userId: string): Promise { + try { + const admin = await requireAdmin(); + + // Empêcher la suppression de son propre compte + if (admin.id === userId) { + throw new AppError(ERROR_CODES.ADMIN.CANNOT_DELETE_SELF); + } + + // Vérifier que l'utilisateur existe + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new AppError(ERROR_CODES.AUTH.USER_NOT_FOUND); + } + + // Supprimer l'utilisateur (cascade supprimera les relations) + await prisma.user.delete({ + where: { id: userId }, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("Forbidden")) { + throw new AppError(ERROR_CODES.AUTH.FORBIDDEN); + } + if (error instanceof AppError) { + throw error; + } + throw new AppError(ERROR_CODES.ADMIN.DELETE_USER_ERROR, {}, error); + } + } + + static async resetUserPassword(userId: string, newPassword: string): Promise { + try { + const admin = await requireAdmin(); + + // Empêcher la modification de son propre mot de passe via cette méthode + if (admin.id === userId) { + throw new AppError(ERROR_CODES.ADMIN.CANNOT_RESET_OWN_PASSWORD); + } + + // Vérifier que l'utilisateur existe + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new AppError(ERROR_CODES.AUTH.USER_NOT_FOUND); + } + + // Hasher le nouveau mot de passe + const bcrypt = await import("bcryptjs"); + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Mettre à jour le mot de passe + await prisma.user.update({ + where: { id: userId }, + data: { password: hashedPassword }, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("Forbidden")) { + throw new AppError(ERROR_CODES.AUTH.FORBIDDEN); + } + if (error instanceof AppError) { + throw error; + } + throw new AppError(ERROR_CODES.ADMIN.RESET_PASSWORD_ERROR, {}, error); + } + } + + static async getUserStats() { + try { + await requireAdmin(); + + const [totalUsers, totalAdmins, usersWithKomga, usersWithPreferences] = + await Promise.all([ + prisma.user.count(), + prisma.user.count({ + where: { + roles: { + has: "ROLE_ADMIN", + }, + }, + }), + prisma.komgaConfig.count(), + prisma.preferences.count(), + ]); + + return { + totalUsers, + totalAdmins, + usersWithKomga, + usersWithPreferences, + }; + } catch (error) { + if (error instanceof Error && error.message.includes("Forbidden")) { + throw new AppError(ERROR_CODES.AUTH.FORBIDDEN); + } + throw new AppError(ERROR_CODES.ADMIN.FETCH_STATS_ERROR, {}, error); + } + } +} +