diff --git a/package.json b/package.json index 0e7bcda..864791c 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,11 @@ "@prisma/client": "5.3.1", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", diff --git a/src/app/(private)/users/TableWrapper.tsx b/src/app/(private)/users/TableWrapper.tsx new file mode 100644 index 0000000..c9ef183 --- /dev/null +++ b/src/app/(private)/users/TableWrapper.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { getUsers } from './actions'; +import { columns } from './columns'; +import { DataTable } from '@/components/table/DataTable'; + +interface Props { + data: Awaited>; + perPage: number; + currentPage: number; +} + +export default function Wrapper({ data, currentPage, perPage }: Props) { + function handleSelectRows(selectedIndeces: Record) { + Object.keys(selectedIndeces).forEach((index) => { + const user = data.users[Number(index)]; + user; // do something with user + }); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/(private)/users/actions.ts b/src/app/(private)/users/actions.ts index 96c0f55..cf1ab5b 100644 --- a/src/app/(private)/users/actions.ts +++ b/src/app/(private)/users/actions.ts @@ -12,9 +12,11 @@ export async function getUsers(args: { date_to?: string; }) { const { page, per_page, username, email, status } = args; + const table = prisma.user; - const getUsersData = async () => { - return await prisma.user.findMany({ + const getCount = () => table.count(); + const getData = async () => { + return await table.findMany({ skip: page * per_page - per_page, take: per_page, select: { @@ -41,10 +43,7 @@ export async function getUsers(args: { }); }; - const [users, count] = await Promise.all([ - getUsersData(), - await prisma.user.count(), - ]); + const [users, count] = await Promise.all([getData(), getCount()]); return { users, count }; } diff --git a/src/app/(private)/users/columns.tsx b/src/app/(private)/users/columns.tsx index e5b6688..5ff4520 100644 --- a/src/app/(private)/users/columns.tsx +++ b/src/app/(private)/users/columns.tsx @@ -3,13 +3,44 @@ import { ColumnDef } from '@tanstack/react-table'; import { getUsers } from './actions'; import { formatDate } from '@/utils/dates'; +import { ArrowUpDown } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; type User = Awaited>['users'][number]; export const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label='Select all' + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label='Select row' + /> + ), + enableSorting: false, + enableHiding: false, + }, { accessorKey: 'username', - header: 'Username', + header: ({ column }) => { + return ( + + ); + }, }, { accessorKey: 'email', diff --git a/src/app/(private)/users/permissions/CreateNewPermissionDialog.tsx b/src/app/(private)/users/permissions/CreateNewPermissionDialog.tsx new file mode 100644 index 0000000..f2e5c64 --- /dev/null +++ b/src/app/(private)/users/permissions/CreateNewPermissionDialog.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +import { Button } from '@/components/ui/button'; +import { Plus } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { NewPermission, newPermissionSchema } from './zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useToast } from '@/components/ui/use-toast'; +import { useRouter } from 'next/navigation'; +import { createNewPermission } from './actions'; + +export default function CreateNewPermissionDialog() { + const [loading, setLoading] = useState(); + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const router = useRouter(); + + const { + reset, + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(newPermissionSchema) }); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + const res = await createNewPermission(data.name); + setLoading(undefined); + + if (res?.error) { + toast({ + title: res.error, + variant: 'destructive', + }); + return; + } + + setOpen(false); + router.refresh(); + reset(); + toast({ + title: 'Permission created successfully', + }); + }; + + return ( + + + + + + +
reset()}> + + Create new permission + + +
+ +
+ + + + + +
+
+
+ ); +} diff --git a/src/app/(private)/users/permissions/TableWrapper.tsx b/src/app/(private)/users/permissions/TableWrapper.tsx new file mode 100644 index 0000000..0750706 --- /dev/null +++ b/src/app/(private)/users/permissions/TableWrapper.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { getPermissions } from './actions'; +import { DataTable } from '@/components/table/DataTable'; + +import CreateNewPermissionDialog from './CreateNewPermissionDialog'; +import { usePermissionTableColumns } from './columns'; + +type Props = { + data: Awaited>; + perPage: number; + currentPage: number; +}; + +export default function TableWrapper({ currentPage, data, perPage }: Props) { + const columns = usePermissionTableColumns(); + + return ( +
+ } + /> +
+ ); +} diff --git a/src/app/(private)/users/permissions/UpdatePermissionComponent.tsx b/src/app/(private)/users/permissions/UpdatePermissionComponent.tsx new file mode 100644 index 0000000..a06b497 --- /dev/null +++ b/src/app/(private)/users/permissions/UpdatePermissionComponent.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { updatePermission } from './actions'; + +import { Button } from '@/components/ui/button'; +import { useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { UpdatePermission, updatePermissionSchema } from './zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/navigation'; +import { useToast } from '@/components/ui/use-toast'; +import useOnKeypress from '@/hooks/useOnKeypress'; + +type Props = { + name: string; + id: number; + onSuccess: () => void; + onCancel: () => void; +}; + +export default function UpdatePermissionComponent({ + id, + name, + onSuccess, + onCancel, +}: Props) { + useOnKeypress('Escape', onCancel); + const router = useRouter(); + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + + const { + formState: { errors, isDirty }, + register, + reset, + handleSubmit, + } = useForm({ + resolver: zodResolver(updatePermissionSchema), + }); + + const onSubmit: SubmitHandler = async (data) => { + setLoading(true); + const res = await updatePermission(id, data.name); + setLoading(false); + + if (res?.error) { + toast({ + title: res.error, + variant: 'destructive', + }); + return; + } + + reset(); + onSuccess(); + router.refresh(); + }; + + return ( +
+ + + +
+ ); +} diff --git a/src/app/(private)/users/permissions/actions.ts b/src/app/(private)/users/permissions/actions.ts new file mode 100644 index 0000000..667cb3e --- /dev/null +++ b/src/app/(private)/users/permissions/actions.ts @@ -0,0 +1,70 @@ +'use server'; + +import { prisma } from '@/prisma'; +import { handleErrorMessage } from '@/utils/error_handling'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +export async function getPermissions(args: { + page: number; + per_page: number; + name?: string; +}) { + const { page, per_page, name } = args; + const table = prisma.permission; + + const getCount = () => table.count(); + const getRows = async () => { + return await table.findMany({ + skip: page * per_page - per_page, + take: per_page, + where: { + name: { + contains: name, + mode: 'insensitive', + }, + }, + }); + }; + + const [permissions, count] = await Promise.all([getRows(), getCount()]); + + return { permissions, count }; +} + +export async function createNewPermission(name: string) { + try { + await prisma.permission.create({ + data: { + name, + }, + }); + } catch (error) { + return handleError(error); + } +} + +export async function updatePermission(id: number, name: string) { + try { + await prisma.permission.update({ + where: { + id, + }, + data: { + name, + }, + }); + } catch (error) { + return handleError(error); + } +} + +function handleError(error: unknown) { + if ( + error instanceof PrismaClientKnownRequestError && + error?.message.includes('Unique constraint failed on the fields: (`name`)') + ) { + return { error: `"${name}" already exist` }; + } + + return handleErrorMessage(error); +} diff --git a/src/app/(private)/users/permissions/columns.tsx b/src/app/(private)/users/permissions/columns.tsx new file mode 100644 index 0000000..c1e8954 --- /dev/null +++ b/src/app/(private)/users/permissions/columns.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import { getPermissions } from './actions'; +import { formatDate } from '@/utils/dates'; + +import { ArrowUpDown, MoreHorizontal, Pencil } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useState } from 'react'; +import UpdatePermissionComponent from './UpdatePermissionComponent'; + +type Permission = Awaited< + ReturnType +>['permissions'][number]; + +export function usePermissionTableColumns() { + const [inEditMode, setIsInEditModa] = useState(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const { name, id } = row.original; + if (inEditMode !== id) return name; + return ( + setIsInEditModa(undefined)} + onSuccess={() => setIsInEditModa(undefined)} + /> + ); + }, + }, + { + accessorKey: 'createdAt', + header: 'Created at', + cell: ({ row }) => { + const date = row.original.createdAt as unknown as string; + return formatDate(date); + }, + }, + { + accessorKey: 'updatedAt', + header: 'Modified at', + cell: ({ row }) => { + const date = row.original.updatedAt as unknown as string; + return formatDate(date); + }, + }, + { + id: 'actions', + cell: ({ row }) => { + return ( + + + + + + Actions + setIsInEditModa(row.original.id)} + > + + Edit + + + + ); + }, + }, + ]; + + return columns; +} diff --git a/src/app/(private)/users/permissions/config.ts b/src/app/(private)/users/permissions/config.ts new file mode 100644 index 0000000..6c3927d --- /dev/null +++ b/src/app/(private)/users/permissions/config.ts @@ -0,0 +1,10 @@ +import { TableSearchElementsConfig } from '@/components/table/TableSearch'; + +export const permissionsSearchConfig: TableSearchElementsConfig = [ + { + type: 'text', + name: 'name', + placeholder: 'Search by name', + label: 'Name', + }, +]; diff --git a/src/app/(private)/users/permissions/page.tsx b/src/app/(private)/users/permissions/page.tsx index 813ff1f..1fe5cdc 100644 --- a/src/app/(private)/users/permissions/page.tsx +++ b/src/app/(private)/users/permissions/page.tsx @@ -1,5 +1,30 @@ -import React from 'react'; +import SearchComponent from '@/components/table/TableSearch'; +import React, { Suspense } from 'react'; +import { permissionsSearchConfig } from './config'; +import { PermissionsTable } from './table'; +import TableLoading from '@/components/table/TableLoading'; -export default function PermissionsPage() { - return
PermissionsPage
; +export const dynamic = 'force-dynamic'; + +export interface PageProps { + searchParams: { + page?: string; + per_page?: string; + name?: string; + }; +} + +export default function PermissionsPage({ searchParams }: PageProps) { + const suspenseKey = new URLSearchParams(searchParams).toString(); + return ( +
+ + } key={suspenseKey}> + + +
+ ); } diff --git a/src/app/(private)/users/permissions/table.tsx b/src/app/(private)/users/permissions/table.tsx new file mode 100644 index 0000000..049a8c5 --- /dev/null +++ b/src/app/(private)/users/permissions/table.tsx @@ -0,0 +1,17 @@ +import { PageProps } from './page'; +import { pareseIntWithDefault } from '@/utils/strings'; +import { getPermissions } from './actions'; +import TableWrapper from './TableWrapper'; + +export async function PermissionsTable({ searchParams }: PageProps) { + const page = pareseIntWithDefault(searchParams.page, 1)!; + const per_page = pareseIntWithDefault(searchParams.per_page, 10)!; + + const data = await getPermissions({ + page, + per_page, + name: searchParams.name, + }); + + return ; +} diff --git a/src/app/(private)/users/permissions/zod.ts b/src/app/(private)/users/permissions/zod.ts new file mode 100644 index 0000000..0f75657 --- /dev/null +++ b/src/app/(private)/users/permissions/zod.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const newPermissionSchema = z + .object({ + name: z.string(), + }) + .refine(({ name }) => name.split('.').length > 1, { + message: + "Permission name must be in the format 'resource.action' (e.g. 'users.create')", + path: ['name'], + }); +export type NewPermission = z.infer; + +export const updatePermissionSchema = newPermissionSchema; +export type UpdatePermission = z.infer; diff --git a/src/app/(private)/users/roles/actions.ts b/src/app/(private)/users/roles/actions.ts index 6c8695f..cd8d181 100644 --- a/src/app/(private)/users/roles/actions.ts +++ b/src/app/(private)/users/roles/actions.ts @@ -8,9 +8,11 @@ export async function getRoles(args: { name?: string; }) { const { page, per_page, name } = args; + const table = prisma.role; + const getCount = () => table.count(); const getRolesData = async () => { - return await prisma.role.findMany({ + return await table.findMany({ skip: page * per_page - per_page, take: per_page, where: { @@ -22,10 +24,7 @@ export async function getRoles(args: { }); }; - const [roles, count] = await Promise.all([ - getRolesData(), - await prisma.role.count(), - ]); + const [roles, count] = await Promise.all([getRolesData(), getCount()]); return { roles, count }; } diff --git a/src/app/(private)/users/roles/columns.tsx b/src/app/(private)/users/roles/columns.tsx index a992bb1..a6b3210 100644 --- a/src/app/(private)/users/roles/columns.tsx +++ b/src/app/(private)/users/roles/columns.tsx @@ -3,10 +3,30 @@ import { ColumnDef } from '@tanstack/react-table'; import { getRoles } from './actions'; import { formatDate } from '@/utils/dates'; +import { Checkbox } from '@/components/ui/checkbox'; -type User = Awaited>['roles'][number]; +type Roles = Awaited>['roles'][number]; -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label='Select all' + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label='Select row' + /> + ), + enableSorting: false, + enableHiding: false, + }, { accessorKey: 'name', header: 'Name', diff --git a/src/app/(private)/users/table.tsx b/src/app/(private)/users/table.tsx index 2f7caa8..f841f51 100644 --- a/src/app/(private)/users/table.tsx +++ b/src/app/(private)/users/table.tsx @@ -1,9 +1,7 @@ -import React from 'react'; import { PageProps } from './page'; import { pareseIntWithDefault } from '@/utils/strings'; import { getUsers } from './actions'; -import { columns } from './columns'; -import { DataTable } from '@/components/table/DataTable'; +import TableWrapper from './TableWrapper'; export default async function Table({ searchParams }: PageProps) { const page = pareseIntWithDefault(searchParams.page, 1)!; @@ -19,16 +17,5 @@ export default async function Table({ searchParams }: PageProps) { date_to: searchParams.date_to, }); - return ( -
- -
- ); + return ; } diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index 82a7cf4..706a254 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -1,10 +1,19 @@ 'use client'; +import React, { useEffect } from 'react'; +import { Table2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +import useUrlSearch from '@/hooks/useUrlSearch'; + import { ColumnDef, flexRender, getCoreRowModel, useReactTable, + SortingState, + getSortedRowModel, + VisibilityState, } from '@tanstack/react-table'; import { @@ -16,9 +25,6 @@ import { TableRow, } from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import useUrlSearch from '@/hooks/useUrlSearch'; - import { Select, SelectContent, @@ -27,6 +33,13 @@ import { SelectValue, } from '@/components/ui/select'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + interface DataTableProps { columns: ColumnDef[]; data: TData[]; @@ -34,6 +47,10 @@ interface DataTableProps { perPage: number; currentPage: number; pathName: string; + visibilitySettings?: boolean; + extraRightTools?: React.ReactNode; + extraLeftTools?: React.ReactNode; + onSelectRows?: (selectedIndeces: Record) => void; } export function DataTable({ @@ -43,26 +60,102 @@ export function DataTable({ perPage, currentPage, pathName, + visibilitySettings = true, + extraLeftTools, + extraRightTools, + onSelectRows, }: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnVisibility, + rowSelection, + }, }); const { search, addQuery, searchParams, handleSelectChange } = useUrlSearch(pathName); + useEffect(() => { + onSelectRows?.(rowSelection); + }, [onSelectRows, rowSelection]); + function changePage(page: number) { addQuery('page', page.toString()); search(); } const pagesCount = Math.ceil(count / perPage); + const selectedRows = table.getFilteredSelectedRowModel().rows; + const currentPageRows = table.getFilteredRowModel().rows; + + const noSelectedRows = selectedRows.length <= 0; + const rowSelectionDisplayText = noSelectedRows + ? null + : `${selectedRows.length} of ${currentPageRows.length} ${ + currentPageRows.length > 1 ? 'rows' : 'row' + } selected`; return (
-
+
+
+ {extraLeftTools} + + {rowSelectionDisplayText && ( +
+ {rowSelectionDisplayText} +
+ )} +
+ +
+ {extraRightTools} + + {visibilitySettings && ( + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + )} +
+
+ +
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/table/TableSearch.tsx b/src/components/table/TableSearch.tsx index cffdb87..04a0741 100644 --- a/src/components/table/TableSearch.tsx +++ b/src/components/table/TableSearch.tsx @@ -69,7 +69,7 @@ export default function SearchComponent(props: Props) {
{props.searchConfig.map((element) => { // ====================================================== Date Range diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 034d69a..19c50ac 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cn } from '@/utils/cn'; import Spinner from './spinner'; const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none', + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-30', { variants: { variant: { @@ -46,7 +46,11 @@ const Button = React.forwardRef( const Comp = asChild ? Slot : 'button'; props.disabled = props.disabled || loading; - props.children = loading ? : props.children; + props.children = loading ? ( + + ) : ( + props.children + ); return ( , + 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..733718a --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,124 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/utils/cn'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + preventClose?: boolean; + } +>(({ className, children, preventClose, ...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, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..b8d73cb --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/cn" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx index 32ee96c..26f9aab 100644 --- a/src/components/ui/spinner.tsx +++ b/src/components/ui/spinner.tsx @@ -1,6 +1,6 @@ -export default function Spinner() { +export default function Spinner({ small }: { small?: boolean }) { return ( -
+
diff --git a/src/hooks/useOnKeypress.ts b/src/hooks/useOnKeypress.ts new file mode 100644 index 0000000..07f4936 --- /dev/null +++ b/src/hooks/useOnKeypress.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; + +export default function useOnKeypress(key: string, callback: () => void) { + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === key) { + callback(); + } + }; + window.addEventListener('keydown', handler); + return () => { + window.removeEventListener('keydown', handler); + }; + }, [callback, key]); +} diff --git a/src/middleware.ts b/src/middleware.ts index 4802638..f5e3d0a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -17,24 +17,27 @@ export async function middleware(request: NextRequest) { if (!token) { if (accessibleToPublic.includes(currentPath)) { + // let the user come in to gain token return NextResponse.next(); } + // if no token and wants to access restricted page, redirect to login const prevLocation = `${currentPath}?${searchParams.toString()}`; return loginRedirect(prevLocation, request); } - // when user is logged in, restrict access to login and signup pages + // here user is logged in, restrict access to login and signup pages if (accessibleToPublic.includes(currentPath)) { const prevLocation = `${currentPath}?${searchParams.toString()}`; - const decodedUri = decodeURIComponent(prevLocation); + const decodedPrevUri = decodeURIComponent(prevLocation); const redirectPathPrefix = '/login?redirect-to='; - if (decodedUri.startsWith(redirectPathPrefix)) { + // redirect if the prev location contains redirect-to query param + if (decodedPrevUri.startsWith(redirectPathPrefix)) { return NextResponse.redirect( - new URL(decodedUri.replace(redirectPathPrefix, ''), request.url) + new URL(decodedPrevUri.replace(redirectPathPrefix, ''), request.url) ); } - + // else redirect to index return indexRedirect(request); } @@ -54,7 +57,8 @@ export async function middleware(request: NextRequest) { } else if (typeof restrictedRoute.permission === 'string') { isPermitted = userPermissions.has(restrictedRoute.permission); } - // const isPermitted = userPermissions.has(restrictedRoute.permission); + + // insufficient permission, redirect to index if (!isPermitted) { Logger.warn(request, ELogMessage.UserTryAccessWithoutPermission); return indexRedirect(request); diff --git a/yarn.lock b/yarn.lock index 49c1a8a..bff7fc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -298,6 +298,21 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-checkbox@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz#98f22c38d5010dd6df4c5744cac74087e3275f4b" + integrity sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/react-collapsible@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" @@ -351,6 +366,27 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-dialog@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300" + integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-controllable-state" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + "@radix-ui/react-direction@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" @@ -409,6 +445,14 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-label@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-2.0.2.tgz#9c72f1d334aac996fdc27b48a8bdddd82108fb6d" + integrity sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-menu@2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e"