diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..7ae4b8f --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,39 @@ +'use client' + +import { Card, CardBody, CardHeader, Tab, Tabs } from '@nextui-org/react' +import NextLink from 'next/link' +import { usePathname } from 'next/navigation' +import { ReactNode } from 'react' + +export default function Layout({ children }: { children: ReactNode }) { + const pathname = usePathname() + + return ( + <> + + +
Gestión de usuarios
+
+ + + + +
{children}
+
+
+ + ) +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..337a7c6 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,13 @@ +'use server' + +import UsersTable from '@/components/users-table/users-table' +import { findUsers } from '@/core/user/infrastructure/actions/find-users' + +export default async function Page() { + const users = await findUsers() + return ( + <> + + + ) +} diff --git a/src/app/books/[id]/page.tsx b/src/app/books/[id]/page.tsx index c7e2a3e..7b5d576 100644 --- a/src/app/books/[id]/page.tsx +++ b/src/app/books/[id]/page.tsx @@ -1,8 +1,10 @@ import { notFound } from 'next/navigation' -import BookPage from '@/components/book-page' +import BookPage from '@/components/book-page/book-page' import { findBook } from '@/core/book/infrastructure/actions/find-book' import getHistoricalLoans from '@/core/loan/infrastructure/actions/get-historical-loans' +import UserResponse from '@/core/user/dto/responses/user.response' +import me from '@/core/user/infrastructure/actions/me' interface PageParameters { id: string @@ -11,6 +13,7 @@ interface PageParameters { export default async function Page({ params }: { params: PageParameters }) { const book = await findBook(params.id) const historicalLoans = await getHistoricalLoans(params.id) + const user = (await me()) as UserResponse if (!book) { return notFound() @@ -18,7 +21,7 @@ export default async function Page({ params }: { params: PageParameters }) { return ( <> - + ) } diff --git a/src/components/book-card/book-card-form.tsx b/src/components/book-card/book-card-form.tsx index 9c003c6..edbfdde 100644 --- a/src/components/book-card/book-card-form.tsx +++ b/src/components/book-card/book-card-form.tsx @@ -21,9 +21,9 @@ function useController(properties: BookCardFormProperties) { const { book, me } = properties const isLoaned = !!book.loan - const isLogged = !!me - const isOwned = isLoaned && isLogged && book.loan.user.id === me.id - const isActive = isLogged && (!isLoaned || isOwned) + const isMember = !!me && me.roles.includes('ROLE_MEMBER') + const isOwned = isLoaned && isMember && book.loan.user.id === me.id + const isActive = isMember && (!isLoaned || isOwned) const currentAction = useFormState( isOwned ? returnBook : loanBook, diff --git a/src/components/book-page.tsx b/src/components/book-page/book-page.tsx similarity index 82% rename from src/components/book-page.tsx rename to src/components/book-page/book-page.tsx index f7d9a99..9ddde3c 100644 --- a/src/components/book-page.tsx +++ b/src/components/book-page/book-page.tsx @@ -11,32 +11,28 @@ import { } from '@nextui-org/react' import { format } from 'date-fns' import { es } from 'date-fns/locale' -import Image from 'next/image' import Link from 'next/link' import BookBreadcrumbs from '@/components/book-breadcrubs/book-breadcrubs' +import BookCard from '@/components/book-card/book-card' import BookResponse from '@/core/book/dto/responses/book.response' import HistoricalLoansResponse from '@/core/loan/dto/responses/historical-loans.response' +import UserResponse from '@/core/user/dto/responses/user.response' interface BookPageProperties { book: BookResponse historicalLoans: HistoricalLoansResponse[] + user: UserResponse } export default function BookPage(properties: BookPageProperties) { - const { book, historicalLoans } = properties + const { book, historicalLoans, user } = properties return ( <>
- {book.title} +

{book.title} @@ -45,14 +41,16 @@ export default function BookPage(properties: BookPageProperties) { {book.authors.join(', ')}

- + {user.roles.includes('ROLE_ADMIN') ? ( + + ) : null}
diff --git a/src/components/header/header-authenticated-menu.tsx b/src/components/header/header-authenticated-menu.tsx index d3d8745..ffbef1c 100644 --- a/src/components/header/header-authenticated-menu.tsx +++ b/src/components/header/header-authenticated-menu.tsx @@ -1,6 +1,6 @@ import { - ArrowLeftOnRectangleIcon, - BookOpenIcon, + AdjustmentsHorizontalIcon, + ArrowLeftEndOnRectangleIcon, PlusIcon, UserIcon, } from '@heroicons/react/24/outline' @@ -21,14 +21,25 @@ interface HeaderAuthenticatedMenuProperties { user: UserResponse } +const OPTIONS = [ + { key: 'profile', roles: ['ROLE_USER'], url: '/settings/profile' }, + { key: 'add_book', roles: ['ROLE_ADMIN'], url: '/books/new' }, + { key: 'admin', roles: ['ROLE_ADMIN'], url: '/admin/users' }, + { key: 'signout', roles: ['ROLE_USER'], url: '/signout' }, +] + export default function HeaderAuthenticatedMenu( properties: HeaderAuthenticatedMenuProperties, ) { const router = useRouter() const { - user: { email, image, name }, + user: { email, image, name, roles }, } = properties + const disabledKeys = OPTIONS.filter( + (option) => !option.roles.some((role) => roles.includes(role)), + ).map((option) => option.key) + return ( <> @@ -42,7 +53,11 @@ export default function HeaderAuthenticatedMenu( fallback={} /> - + router.push('/settings/profile')} @@ -63,15 +78,18 @@ export default function HeaderAuthenticatedMenu( Añadir libro } + key="admin" + startContent={} + onClick={() => router.push('/admin/users')} > - Mis libros + Administrar router.push('/signout')} - startContent={} + startContent={ + + } > Cerrar sesión diff --git a/src/components/users-table/users-table-cell-action.tsx b/src/components/users-table/users-table-cell-action.tsx new file mode 100644 index 0000000..0b5c906 --- /dev/null +++ b/src/components/users-table/users-table-cell-action.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Switch } from '@nextui-org/react' + +import UserResponse from '@/core/user/dto/responses/user.response' +import enableUser from '@/core/user/infrastructure/actions/enable-user' + +interface UsersTableCellActionProperties { + user: UserResponse +} + +export function UsersTableCellAction( + properties: UsersTableCellActionProperties, +) { + const { user } = properties + const enabled = user.roles.includes('ROLE_MEMBER') + if (user.roles.includes('ROLE_ADMIN')) { + return null + } + + const selectHandler = async (isSelected: boolean) => { + await enableUser(user.email, isSelected) + } + + return ( + + Activar + + ) +} diff --git a/src/components/users-table/users-table-cell.tsx b/src/components/users-table/users-table-cell.tsx new file mode 100644 index 0000000..813327c --- /dev/null +++ b/src/components/users-table/users-table-cell.tsx @@ -0,0 +1,46 @@ +'use client' + +import { User } from '@nextui-org/react' +import { Key } from 'react' + +import { UsersTableCellAction } from '@/components/users-table/users-table-cell-action' +import UserResponse from '@/core/user/dto/responses/user.response' +import gravatar from '@/lib/utils/gravatar' + +interface UsersTableCellProperties { + columnKey: Key + user: UserResponse +} + +export default function UsersTableCell(properties: UsersTableCellProperties) { + const { columnKey, user } = properties + + switch (columnKey) { + case 'email': { + return ( + + {user.email} + + ) + } + case 'roles': { + return ( +
+

+ {user.roles.join(', ')} +

+
+ ) + } + case 'actions': { + return + } + default: { + return user[columnKey as keyof UserResponse] + } + } +} diff --git a/src/components/users-table/users-table.tsx b/src/components/users-table/users-table.tsx new file mode 100644 index 0000000..72f38ce --- /dev/null +++ b/src/components/users-table/users-table.tsx @@ -0,0 +1,96 @@ +'use client' + +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from '@nextui-org/react' +import { SortDescriptor } from '@react-types/shared/src/collections' +import { useCallback, useState } from 'react' + +import UsersTableCell from '@/components/users-table/users-table-cell' +import UserResponse from '@/core/user/dto/responses/user.response' + +const COLUMNS = [ + { name: 'Email', uid: 'email' }, + { name: 'Roles', uid: 'roles' }, + { name: 'Acciones', uid: 'actions' }, +] + +interface UsersTableProperties { + users: UserResponse[] +} + +export default function UsersTable(properties: UsersTableProperties) { + const { sortDescriptor, sortHandle, users } = useController(properties) + + return ( + <> + + + {(column) => ( + + {column.name} + + )} + + + {(item) => ( + + {(columnKey) => ( + + + + )} + + )} + +
+ + ) +} + +function useController(properties: UsersTableProperties) { + const { users } = properties + + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'name', + direction: 'ascending', + }) + + const sortHandle = useCallback( + (nextSortDescriptor: SortDescriptor) => { + setSortDescriptor(nextSortDescriptor) + + return { + items: sortUsers(users, nextSortDescriptor), + } + }, + [users], + ) + + return { sortDescriptor, sortHandle, users: sortUsers(users, sortDescriptor) } +} + +function sortUsers(users: UserResponse[], sortDescriptor: SortDescriptor) { + const { column, direction } = sortDescriptor + + return users.sort((a, b) => { + const first = a[column as keyof UserResponse] + const second = b[column as keyof UserResponse] + let cmp = first < second ? -1 : 1 + + if (direction === 'descending') { + cmp *= -1 + } + + return cmp + }) +} diff --git a/src/lib/auth/auth.config.ts b/src/lib/auth/auth.config.ts index 0c4bca6..fe24ab2 100644 --- a/src/lib/auth/auth.config.ts +++ b/src/lib/auth/auth.config.ts @@ -6,9 +6,17 @@ import prisma from '@/lib/prisma/prisma' const authConfig = { accessControl: [ + { + path: '^/admin', + roles: 'ROLE_ADMIN', + }, + { + path: '^/books/new', + roles: 'ROLE_ADMIN', + }, { path: '^/books/\\w+/edit', - roles: 'IS_AUTHENTICATED', + roles: 'ROLE_ADMIN', }, { path: '^/settings',