diff --git a/drizzle.config.ts b/drizzle.config.ts index bbdb7e4..816a735 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'drizzle-kit'; import env from '@/env'; export default defineConfig({ - schema: './src/database/schema/index.ts', + schema: './src/database/tables/index.ts', dialect: 'postgresql', out: './drizzle', dbCredentials: { diff --git a/package.json b/package.json index e37a3cb..021b5c3 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", + "@tanstack/react-query": "^5.52.1", "arctic": "^1.9.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb39791..29a5663 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,21 @@ dependencies: '@radix-ui/react-popover': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.1.1 + version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.4)(react@18.3.1) '@radix-ui/react-toast': specifier: ^1.2.1 version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.52.1 + version: 5.52.1(react@18.3.1) arctic: specifier: ^1.9.2 version: 1.9.2 @@ -1376,6 +1385,10 @@ packages: requiresBuild: true optional: true + /@radix-ui/number@1.1.0: + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + dev: false + /@radix-ui/primitive@1.0.1: resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} dependencies: @@ -2036,6 +2049,74 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /@radix-ui/react-scroll-area@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==} + 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 + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@types/react': 18.3.4 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-select@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ==} + 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 + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.4)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.4 + '@types/react-dom': 18.3.0 + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.7(@types/react@18.3.4)(react@18.3.1) + dev: false + /@radix-ui/react-slot@1.0.2(@types/react@18.3.4)(react@18.3.1): resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -2208,6 +2289,19 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-use-previous@1.1.0(@types/react@18.3.4)(react@18.3.1): + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.4 + react: 18.3.1 + dev: false + /@radix-ui/react-use-rect@1.1.0(@types/react@18.3.4)(react@18.3.1): resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -2275,6 +2369,19 @@ packages: tslib: 2.6.3 dev: false + /@tanstack/query-core@5.52.0: + resolution: {integrity: sha512-U1DOEgltjUwalN6uWYTewSnA14b+tE7lSylOiASKCAO61ENJeCq9VVD/TXHA6O5u9+6v5+UgGYBSccTKDoyMqw==} + dev: false + + /@tanstack/react-query@5.52.1(react@18.3.1): + resolution: {integrity: sha512-soyn4dNIUZ8US8NaPVXv06gkZFHaZnPfKWPDjRJjFRW3Y7WZ0jx72eT6zhw3VQlkMPysmXye8l35ewPHspKgbQ==} + peerDependencies: + react: ^18 || ^19 + dependencies: + '@tanstack/query-core': 5.52.0 + react: 18.3.1 + dev: false + /@tybys/wasm-util@0.8.3: resolution: {integrity: sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q==} requiresBuild: true diff --git a/src/app/(private)/dashboard/(admin)/users/layout.tsx b/src/app/(private)/dashboard/(admin)/users/layout.tsx new file mode 100644 index 0000000..69fb83e --- /dev/null +++ b/src/app/(private)/dashboard/(admin)/users/layout.tsx @@ -0,0 +1,20 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Users | Sainseni Dashboard', + authors: [ + { + name: 'Khoironi Kurnia Syah', + url: 'https://zekhoi.dev', + }, + ], + description: 'Community for community', +}; + +export default function UsersLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return <>{children}; +} diff --git a/src/app/(private)/dashboard/(admin)/users/page.tsx b/src/app/(private)/dashboard/(admin)/users/page.tsx new file mode 100644 index 0000000..278d927 --- /dev/null +++ b/src/app/(private)/dashboard/(admin)/users/page.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { Menu, Users } from 'lucide-react'; +import { useState } from 'react'; + +import { useGetRoles } from '@/lib/queries/roles.query'; +import { useGetUsers } from '@/lib/queries/users.query'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import RoleFormDialog from './role-form'; +export default function Component() { + const { + data: roleData, + isLoading: isRoleLoading, + error: roleError, + } = useGetRoles({}); + + const { + data: userData, + isLoading: isuserLoading, + error: userError, + } = useGetUsers({}); + const [newMember, setNewMember] = useState({ + name: '', + email: '', + role: '', + }); + const [newRole, setNewRole] = useState(''); + const [editingRole, setEditingRole] = useState(''); + console.error(userData, 'userData'); + + return ( +
+
+

+ + User Management +

+
+ +
+
+ +
+ + + User List + + + + + + + + Name + + + Email + + Role + + Actions + + + + + {userData && + userData.map((member) => ( + + + {member.name} + + + {member.email} + + + {member.roleName} + + + + + + + + + Actions + + + Edit User + + + Change Role + + + + Delete User + + + + + + ))} + +
+
+
+
+
+
+ ); +} diff --git a/src/app/(private)/dashboard/(admin)/users/role-form.tsx b/src/app/(private)/dashboard/(admin)/users/role-form.tsx new file mode 100644 index 0000000..e7b4282 --- /dev/null +++ b/src/app/(private)/dashboard/(admin)/users/role-form.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { + AlertCircle, + Loader2, + Pencil, + Plus, + Settings, + Trash2, +} from 'lucide-react'; +import { useState } from 'react'; + +import { useGetRoles } from '@/lib/queries/roles.query'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +export default function RoleFormDialog() { + const [newRoleName, setNewRoleName] = useState(''); + const { + data: roleData, + isLoading: isRoleLoading, + error: roleError, + refetch: refetchRoles, + } = useGetRoles({}); + + const handleAddRole = () => { + // Implement add role logic here + console.log('Adding new role:', newRoleName); + setNewRoleName(''); + refetchRoles(); + }; + + const handleEditRole = (roleId: string) => { + // Implement edit role logic here + console.log('Editing role:', roleId); + }; + + const handleDeleteRole = (roleId: string) => { + // Implement delete role logic here + console.log('Deleting role:', roleId); + refetchRoles(); + }; + + return ( + + + + + + + Manage Roles + +
+ + + Add New Role + + +
+ + setNewRoleName(e.target.value) + } + /> + +
+
+
+ + + Existing Roles + + + {isRoleLoading ? ( +
+ +
+ ) : roleError ? ( + + + Error + + Failed to load roles. Please try again + later. + + + ) : ( + +
    + {roleData && + roleData.map((role) => ( +
  • + {role.name} +
    + + +
    +
  • + ))} +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/(private)/dashboard/(admin)/users/user-form.tsx b/src/app/(private)/dashboard/(admin)/users/user-form.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(private)/layout.tsx b/src/app/(private)/layout.tsx index 60922b0..46685cb 100644 --- a/src/app/(private)/layout.tsx +++ b/src/app/(private)/layout.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import { redirect } from 'next/navigation'; -import { validateRequest } from '@/lib/auth'; +import { checkAdmin, validateRequest } from '@/lib/auth'; import PrivateHeader from '@/components/private/header'; @@ -27,9 +27,11 @@ export default async function PrivateLayout({ return redirect('/auth/signin'); } + const isAdmin = await checkAdmin(); + return (
- + {children}
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9702c58..55c674c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,6 @@ import { Inter } from 'next/font/google'; import './globals.css'; -import { Toaster } from '@/components/ui/toaster'; - const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { @@ -27,7 +25,6 @@ export default function RootLayout({
{children}
- ); diff --git a/src/app/template.tsx b/src/app/template.tsx new file mode 100644 index 0000000..4dd7d39 --- /dev/null +++ b/src/app/template.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import dynamic from 'next/dynamic'; + +import { Toaster } from '@/components/ui/toaster'; +export const queryClient = new QueryClient(); + +function RootTemplate({ children }: React.PropsWithChildren) { + return ( + + {children} + + + ); +} +export default dynamic(() => Promise.resolve(RootTemplate), { + ssr: false, +}); diff --git a/src/components/private/header.tsx b/src/components/private/header.tsx index a9baa43..7f6c524 100644 --- a/src/components/private/header.tsx +++ b/src/components/private/header.tsx @@ -4,7 +4,8 @@ import { CircleUser, Menu, Sparkles } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { signOut } from '@/lib/auth/action'; +import { signOut } from '@/lib/actions/auth'; +import { DatabaseUserAttributes } from '@/lib/auth'; import { Button } from '@/components/ui/button'; import { @@ -17,18 +18,24 @@ import { } from '@/components/ui/dropdown-menu'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; -const navbarMenu = [ - { name: 'Dashboard', url: '/dashboard' }, - { name: 'Projects', url: '/dashboard/projects' }, - { name: 'Friends', url: '/dashboard/friends' }, - { name: 'References', url: '/dashboard/references' }, - { name: 'Events', url: '/dashboard/events' }, - { name: 'Users', url: '/dashboard/users' }, - { name: 'Roles', url: '/dashboard/roles' }, - { name: 'Keys', url: '/dashboard/keys' }, -]; +type PrivateHeaderProps = { + isAdmin: boolean; + userData: DatabaseUserAttributes; +}; +export default function PrivateHeader({ + isAdmin = false, + userData, +}: PrivateHeaderProps) { + const navbarMenu = [ + { name: 'Dashboard', url: '/dashboard', isAdmin: false }, + { name: 'Projects', url: '/dashboard/projects', isAdmin: false }, + { name: 'Friends', url: '/dashboard/friends', isAdmin: false }, + { name: 'References', url: '/dashboard/references', isAdmin: false }, + { name: 'Events', url: '/dashboard/events', isAdmin: false }, + { name: 'Users', url: '/dashboard/users', isAdmin: true }, + { name: 'Keys', url: '/dashboard/keys', isAdmin: true }, + ]; -export default function PrivateHeader() { const pathname = usePathname(); const isActive = (path: string) => { @@ -42,25 +49,31 @@ export default function PrivateHeader() {
@@ -110,7 +123,7 @@ export default function PrivateHeader() { - My Name + {userData.name} Settings diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..cbe5a36 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/database/index.ts b/src/database/index.ts index 02f25d4..b9185ff 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -5,8 +5,8 @@ import postgres from 'postgres'; import env from '@/env'; -import * as schema from './schema'; -export * from './schema'; +import * as schema from './tables'; +export * from './tables'; let database: PostgresJsDatabase; let pg: ReturnType; diff --git a/src/database/schema/index.ts b/src/database/schema/index.ts deleted file mode 100644 index 682d360..0000000 --- a/src/database/schema/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './event.schema'; -export * from './friend.schema'; -export * from './friend.schema'; -export * from './project.schema'; -export * from './role.schema'; -export * from './session.schema'; -export * from './user.schema'; diff --git a/src/database/seed.ts b/src/database/seed.ts index c2072a5..cf9aed6 100644 --- a/src/database/seed.ts +++ b/src/database/seed.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; -import * as table from '@/database/schema'; +import * as table from '@/database/tables'; import env from '@/env'; import { database, pg } from './index'; @@ -10,14 +10,14 @@ async function main() { throw new Error('Seed is only allowed in development environment'); } - const [adminRole, userRole] = await database + const [adminRole, memberRole] = await database .insert(table.role) .values([ { name: 'admin', }, { - name: 'user', + name: 'member', }, ]) .onConflictDoNothing() @@ -38,7 +38,7 @@ async function main() { email: 'prabowo@indonesia.go.id', name: 'Prabowo Subianto', accountType: 'github', - roleId: userRole.id, + roleId: memberRole.id, }, ]) .onConflictDoNothing() diff --git a/src/database/schema/event.schema.ts b/src/database/tables/event.table.ts similarity index 100% rename from src/database/schema/event.schema.ts rename to src/database/tables/event.table.ts diff --git a/src/database/schema/friend.schema.ts b/src/database/tables/friend.table.ts similarity index 100% rename from src/database/schema/friend.schema.ts rename to src/database/tables/friend.table.ts diff --git a/src/database/tables/index.ts b/src/database/tables/index.ts new file mode 100644 index 0000000..223d1fc --- /dev/null +++ b/src/database/tables/index.ts @@ -0,0 +1,7 @@ +export * from './event.table'; +export * from './friend.table'; +export * from './friend.table'; +export * from './project.table'; +export * from './role.table'; +export * from './session.table'; +export * from './user.table'; diff --git a/src/database/schema/project.schema.ts b/src/database/tables/project.table.ts similarity index 100% rename from src/database/schema/project.schema.ts rename to src/database/tables/project.table.ts diff --git a/src/database/schema/role.schema.ts b/src/database/tables/role.table.ts similarity index 62% rename from src/database/schema/role.schema.ts rename to src/database/tables/role.table.ts index 3ccdb87..4c32c5a 100644 --- a/src/database/schema/role.schema.ts +++ b/src/database/tables/role.table.ts @@ -1,9 +1,10 @@ -import { pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { v7 as uuidv7 } from 'uuid'; export const role = pgTable('roles', { id: uuid('id').primaryKey().$defaultFn(uuidv7), name: text('name').unique().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), }); export type Role = typeof role.$inferSelect; diff --git a/src/database/schema/session.schema.ts b/src/database/tables/session.table.ts similarity index 91% rename from src/database/schema/session.schema.ts rename to src/database/tables/session.table.ts index f3d171b..257a918 100644 --- a/src/database/schema/session.schema.ts +++ b/src/database/tables/session.table.ts @@ -1,6 +1,6 @@ import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; -import { user } from './user.schema'; +import { user } from './user.table'; export const session = pgTable('session', { id: text('id').primaryKey(), diff --git a/src/database/schema/user.schema.ts b/src/database/tables/user.table.ts similarity index 85% rename from src/database/schema/user.schema.ts rename to src/database/tables/user.table.ts index c0726f3..c302b3f 100644 --- a/src/database/schema/user.schema.ts +++ b/src/database/tables/user.table.ts @@ -1,7 +1,7 @@ import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { v7 as uuidv7 } from 'uuid'; -import { role } from '@/database/schema/role.schema'; +import { role } from '@/database/tables'; export const accountTypeEnum = pgEnum('type', ['google', 'github']); @@ -19,3 +19,4 @@ export const user = pgTable('users', { }); export type User = typeof user.$inferSelect; +export type ModifiedUser = Pick; diff --git a/src/lib/auth/action.ts b/src/lib/actions/auth.ts similarity index 100% rename from src/lib/auth/action.ts rename to src/lib/actions/auth.ts diff --git a/src/lib/actions/index.ts b/src/lib/actions/index.ts new file mode 100644 index 0000000..aadee9f --- /dev/null +++ b/src/lib/actions/index.ts @@ -0,0 +1,3 @@ +export * from './auth'; +export * from './roles'; +export * from './users'; diff --git a/src/lib/actions/roles.ts b/src/lib/actions/roles.ts new file mode 100644 index 0000000..201fa0d --- /dev/null +++ b/src/lib/actions/roles.ts @@ -0,0 +1,15 @@ +'use server'; + +import { and, desc, ilike } from 'drizzle-orm'; + +import { RoleSearch } from '@/lib/schema'; + +import { database, role } from '@/database'; + +export async function getRoles(search: RoleSearch) { + return await database + .select() + .from(role) + .where(and(search.name ? ilike(role.name, search.name) : undefined)) + .orderBy(desc(role.createdAt)); +} diff --git a/src/lib/actions/users.ts b/src/lib/actions/users.ts new file mode 100644 index 0000000..5989536 --- /dev/null +++ b/src/lib/actions/users.ts @@ -0,0 +1,29 @@ +'use server'; + +import { and, desc, eq, ilike, inArray } from 'drizzle-orm'; + +import { UserSearch } from '@/lib/schema'; + +import { database, role, user } from '@/database'; + +export async function getUsers(search: UserSearch) { + return await database + .select({ + id: user.id, + name: user.name, + email: user.email, + roleName: role.name, + }) + .from(user) + .fullJoin(role, eq(user.roleId, role.id)) + .where( + and( + search.name ? ilike(user.name, search.name) : undefined, + search.email ? eq(user.email, search.email) : undefined, + search.roleIds + ? inArray(user.roleId, search.roleIds) + : undefined, + ), + ) + .orderBy(desc(user.createdAt)); +} diff --git a/src/lib/auth/account.ts b/src/lib/auth/account.ts index 677d456..31e68e4 100644 --- a/src/lib/auth/account.ts +++ b/src/lib/auth/account.ts @@ -12,11 +12,11 @@ export async function createUser( data: CreateUserProps, type: 'google' | 'github', ) { - const userRole = await database.query.role.findFirst({ + const memberRole = await database.query.role.findFirst({ where: eq(role.name, 'user'), }); - if (!userRole) { + if (!memberRole) { throw new Error('User role not found'); } @@ -26,7 +26,7 @@ export async function createUser( accountType: type, accountId: data.accountId, email: data.email, - roleId: userRole.id, + roleId: memberRole.id, name: data.name, avatar: data.avatar, }) diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index d5ec15f..45cfaf1 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -4,8 +4,8 @@ import { Lucia } from 'lucia'; import { cookies } from 'next/headers'; import { cache } from 'react'; +import type { User as DatabaseUser } from '@/database'; import { adapter } from '@/database'; -import type { User as DatabaseUser } from '@/database/schema'; import env from '@/env'; export * from './account'; @@ -20,7 +20,7 @@ export const lucia = new Lucia(adapter, { getUserAttributes: (attribute) => { return { id: attribute.id, - role: attribute.roleId, + roleId: attribute.roleId, name: attribute.name, }; }, @@ -29,10 +29,15 @@ export const lucia = new Lucia(adapter, { declare module 'lucia' { interface Register { Lucia: typeof lucia; - DatabaseUserAttributes: DatabaseUser; + DatabaseUserAttributes: DatabaseUserAttributes; } } +export type DatabaseUserAttributes = Pick< + DatabaseUser, + 'id' | 'roleId' | 'name' +>; + export const validateRequest = cache( async (): Promise< { user: User; session: Session } | { user: null; session: null } diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 0da6e15..d0cadf6 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -6,7 +6,7 @@ import 'server-only'; import { lucia, validateRequest } from '@/lib/auth'; import { AuthenticationError } from '@/lib/error'; -import { database, role, User } from '@/database'; +import { database, User } from '@/database'; export const getCurrentUser = cache(async () => { const { user } = await validateRequest(); @@ -24,21 +24,14 @@ export const assertAuthenticated = async () => { return user; }; -export const checkRole = async () => { +export const checkAdmin = async () => { const user = await assertAuthenticated(); - const [admin] = await database - .select({ - name: role.name, - }) - .from(role) - .where(eq(role.id, user?.role)); - - return admin?.name !== 'admin'; - // if (!isAdmin) { - // throw new AuthorizationError(); - // } - // return isAdmin; + const adminRole = await database.query.role.findFirst({ + where: (role) => eq(role.id, user?.roleId), + }); + + return adminRole?.name !== 'admin'; }; export async function setSession(user: User) { diff --git a/src/lib/features/README.md b/src/lib/features/README.md deleted file mode 100644 index efcc379..0000000 --- a/src/lib/features/README.md +++ /dev/null @@ -1 +0,0 @@ -# Every logic is here diff --git a/src/lib/features/users.ts b/src/lib/features/users.ts new file mode 100644 index 0000000..01abce4 --- /dev/null +++ b/src/lib/features/users.ts @@ -0,0 +1,22 @@ +'use server'; + +import { eq } from 'drizzle-orm'; + +import { database, ModifiedUser, user as userTable } from '@/database'; + +export async function editUser(userId: string, data: Partial) { + const [selectedUser] = await database + .select({ id: userTable.id }) + .from(userTable) + .where(eq(userTable.id, userId)); + + if (!selectedUser) { + throw new Error('User not found'); + } + + await database + .update(userTable) + .set({ ...data }) + .where(eq(userTable.id, selectedUser.id)) + .execute(); +} diff --git a/src/lib/queries/roles.query.ts b/src/lib/queries/roles.query.ts new file mode 100644 index 0000000..c803e46 --- /dev/null +++ b/src/lib/queries/roles.query.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getRoles } from '@/lib/actions'; +import { RoleSearch } from '@/lib/schema'; + +export function useGetRoles(search: RoleSearch) { + return useQuery({ + queryKey: ['roles', search], + queryFn: () => getRoles(search), + }); +} diff --git a/src/lib/queries/users.query.ts b/src/lib/queries/users.query.ts new file mode 100644 index 0000000..27b9747 --- /dev/null +++ b/src/lib/queries/users.query.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getUsers } from '@/lib/actions'; +import { UserSearch } from '@/lib/schema'; + +export function useGetUsers(search: UserSearch) { + return useQuery({ + queryKey: ['users', search], + queryFn: () => getUsers(search), + }); +} diff --git a/src/lib/schema/index.ts b/src/lib/schema/index.ts new file mode 100644 index 0000000..e525192 --- /dev/null +++ b/src/lib/schema/index.ts @@ -0,0 +1,2 @@ +export * from './roles.schema'; +export * from './users.schema'; diff --git a/src/lib/schema/roles.schema.ts b/src/lib/schema/roles.schema.ts new file mode 100644 index 0000000..ef3cbbf --- /dev/null +++ b/src/lib/schema/roles.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const RoleSearchSchema = z.object({ + name: z.string().optional(), +}); + +export type RoleSearch = z.infer; diff --git a/src/lib/schema/users.schema.ts b/src/lib/schema/users.schema.ts new file mode 100644 index 0000000..544fd89 --- /dev/null +++ b/src/lib/schema/users.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const UserSearchSchema = z.object({ + name: z.string().optional(), + email: z.string().optional(), + roleIds: z.array(z.string()).optional(), +}); + +export type UserSearch = z.infer;