diff --git a/package.json b/package.json index 1434778..e37a3cb 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build", + "start": "next start", "db:setup": "./scripts/setup.sh", "db:push": "drizzle-kit push --config=drizzle.config.ts", "db:migrate": "tsx ./src/database/migrate.ts", @@ -25,6 +26,7 @@ "dependencies": { "@lucia-auth/adapter-drizzle": "^1.1.0", "@next/env": "^14.2.6", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fa7c92..cb39791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@next/env': specifier: ^14.2.6 version: 14.2.6 + '@radix-ui/react-avatar': + 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-dialog': 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) @@ -1403,6 +1406,29 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /@radix-ui/react-avatar@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-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==} + 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/react-context': 1.1.0(@types/react@18.3.4)(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-collection@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-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: diff --git a/src/app/(home)/layout.tsx b/src/app/(home)/layout.tsx index 5e86594..d839fb1 100644 --- a/src/app/(home)/layout.tsx +++ b/src/app/(home)/layout.tsx @@ -1,7 +1,7 @@ import { validateRequest } from '@/lib/auth'; -import Footer from '@/components/common/footer'; -import Navbar from '@/components/common/navbar'; +import Footer from '@/components/public/footer'; +import Header from '@/components/public/header'; type HomeLayoutProps = { children: React.ReactNode; @@ -11,7 +11,7 @@ export default async function HomeLayout({ children }: HomeLayoutProps) { return (
- +
{children}
diff --git a/src/app/(private)/dashboard/page.tsx b/src/app/(private)/dashboard/page.tsx new file mode 100644 index 0000000..269aff0 --- /dev/null +++ b/src/app/(private)/dashboard/page.tsx @@ -0,0 +1,380 @@ +import { + Activity, + ArrowUpRight, + CreditCard, + DollarSign, + Users, +} from 'lucide-react'; +import Link from 'next/link'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +export default function DashboardPage() { + return ( +
+
+
+ + + + Total Revenue + + + + +
$45,231.89
+

+ +20.1% from last month +

+
+
+ + + + Subscriptions + + + + +
+2350
+

+ +180.1% from last month +

+
+
+ + + + Sales + + + + +
+12,234
+

+ +19% from last month +

+
+
+ + + + Active Now + + + + +
+573
+

+ +201 since last hour +

+
+
+
+
+ + +
+ Transactions + + Recent transactions from your store. + +
+ +
+ + + + + Customer + + Type + + + Status + + + Date + + + Amount + + + + + + +
+ Liam Johnson +
+
+ liam@example.com +
+
+ + Sale + + + + Approved + + + + 2023-06-23 + + + $250.00 + +
+ + +
+ Olivia Smith +
+
+ olivia@example.com +
+
+ + Refund + + + + Declined + + + + 2023-06-24 + + + $150.00 + +
+ + +
+ Noah Williams +
+
+ noah@example.com +
+
+ + Subscription + + + + Approved + + + + 2023-06-25 + + + $350.00 + +
+ + +
+ Emma Brown +
+
+ emma@example.com +
+
+ + Sale + + + + Approved + + + + 2023-06-26 + + + $450.00 + +
+ + +
+ Liam Johnson +
+
+ liam@example.com +
+
+ + Sale + + + + Approved + + + + 2023-06-27 + + + $550.00 + +
+
+
+
+
+ + + Recent Sales + + +
+ + + OM + +
+

+ Olivia Martin +

+

+ olivia.martin@email.com +

+
+
+ +$1,999.00 +
+
+
+ + + JL + +
+

+ Jackson Lee +

+

+ jackson.lee@email.com +

+
+
+ +$39.00 +
+
+
+ + + IN + +
+

+ Isabella Nguyen +

+

+ isabella.nguyen@email.com +

+
+
+ +$299.00 +
+
+
+ + + WK + +
+

+ William Kim +

+

+ will@email.com +

+
+
+ +$99.00 +
+
+
+ + + SD + +
+

+ Sofia Davis +

+

+ sofia.davis@email.com +

+
+
+ +$39.00 +
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(private)/layout.tsx b/src/app/(private)/layout.tsx new file mode 100644 index 0000000..80d253b --- /dev/null +++ b/src/app/(private)/layout.tsx @@ -0,0 +1,36 @@ +import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; + +import { validateRequest } from '@/lib/auth'; + +import PrivateHeader from '@/components/private/header'; + +export const metadata: Metadata = { + title: 'Dashboard | Sainseni Community', + authors: [ + { + name: 'Khoironi Kurnia Syah', + url: 'https://zekhoi.dev', + }, + ], + description: 'Community for community', +}; + +export default async function PrivateLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { session, user } = await validateRequest(); + + if (!session || !user) { + return redirect('/auth'); + } + + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts index 2ddcfce..a9e3eef 100644 --- a/src/app/api/auth/github/callback/route.ts +++ b/src/app/api/auth/github/callback/route.ts @@ -73,7 +73,7 @@ export async function GET(request: Request): Promise { const existingAccount = await getUserByGithubId(githubUser.id); if (existingAccount) { - await setSession(existingAccount.id); + await setSession(existingAccount); return new Response(null, { status: 302, headers: { @@ -96,14 +96,14 @@ export async function GET(request: Request): Promise { githubUser.email = getPrimaryEmail(githubUserEmails); } - const userId = await createUserViaGithub({ + const user = await createUserViaGithub({ accountId: githubUser.id, email: githubUser.email, name: githubUser.name, avatar: githubUser.avatar_url, }); - await setSession(userId); + await setSession(user); return new Response(null, { status: 302, headers: { diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts index 54ca71c..2d9f081 100644 --- a/src/app/api/auth/google/callback/route.ts +++ b/src/app/api/auth/google/callback/route.ts @@ -44,7 +44,7 @@ export async function GET(request: Request): Promise { const existingAccount = await getUserByGoogleId(googleUser.sub); if (existingAccount) { - await setSession(existingAccount.id); + await setSession(existingAccount); return new Response(null, { status: 302, headers: { @@ -53,13 +53,13 @@ export async function GET(request: Request): Promise { }); } - const userId = await createUserViaGoogle({ + const user = await createUserViaGoogle({ accountId: googleUser.sub, email: googleUser.email, name: googleUser.name, avatar: googleUser.picture, }); - await setSession(userId); + await setSession(user); return new Response(null, { status: 302, headers: { diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 7ca36ff..a8bbd2a 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -3,8 +3,8 @@ import { redirect } from 'next/navigation'; import { validateRequest } from '@/lib/auth'; -import Footer from '@/components/common/footer'; -import Navbar from '@/components/common/navbar'; +import Footer from '@/components/public/footer'; +import Header from '@/components/public/header'; export const metadata: Metadata = { title: 'Signin | Sainseni Community', @@ -22,15 +22,15 @@ export default async function AuthLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const user = await validateRequest(); + const { session } = await validateRequest(); - if (user.session) { + if (session) { return redirect('/dashboard'); } return (
- +
{children}
diff --git a/src/app/error.tsx b/src/app/error.tsx index f323432..7c9f988 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -2,25 +2,29 @@ import { motion } from 'framer-motion'; import { AlertTriangle } from 'lucide-react'; -import { NextPageContext } from 'next'; import Head from 'next/head'; import Link from 'next/link'; +import { getStatusCode } from '@/lib/error'; + import { Button } from '@/components/ui/button'; interface ErrorProps { - statusCode?: number; + error: Error & { digest?: string }; } -function Error({ statusCode }: ErrorProps) { +function Error({ error }: ErrorProps) { + const statusCode = getStatusCode(error); + + const errorTitle = `An Error Occurred: ${statusCode}`; + const errorDescription = error.message + ? error.message + : 'An unexpected error has occurred.'; + return ( <> - - {statusCode - ? `${statusCode} - Error Occurred` - : 'An Error Occurred'} - + {errorTitle}
- Cosmic Glitch Detected + {errorTitle} - {statusCode - ? `Houston, we've encountered a ${statusCode} anomaly.` - : 'An unexpected error has warped our space-time continuum.'} + {errorDescription} { - const statusCode = res ? res.statusCode : err ? err.statusCode : 404; - return { statusCode }; -}; - export default Error; diff --git a/src/components/private/header.tsx b/src/components/private/header.tsx new file mode 100644 index 0000000..465d64b --- /dev/null +++ b/src/components/private/header.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { CircleUser, Menu, Sparkles } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} 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' }, +]; + +export default function PrivateHeader() { + const pathname = usePathname(); + + const isActive = (path: string) => { + if (path === '/dashboard') { + return pathname === path; + } + return pathname.startsWith(path); + }; + + return ( +
+ + + + + + + + + + + + + + + My Name + + Settings + + Logout + + +
+ ); +} diff --git a/src/components/common/footer.tsx b/src/components/public/footer.tsx similarity index 100% rename from src/components/common/footer.tsx rename to src/components/public/footer.tsx diff --git a/src/components/common/navbar.tsx b/src/components/public/header.tsx similarity index 98% rename from src/components/common/navbar.tsx rename to src/components/public/header.tsx index 131102f..7b851dc 100644 --- a/src/components/common/navbar.tsx +++ b/src/components/public/header.tsx @@ -17,8 +17,6 @@ const navbarMenu = [ { name: 'Projects', url: '/projects' }, { name: 'Friends', url: '/friends' }, { name: 'References', url: '/references' }, - { name: 'About', url: '/about' }, - { name: 'Blog', url: '/blog' }, ]; type NavbarProps = { diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..fd7b3f0 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..961ca04 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ 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< + HTMLTableRowElement, + React.HTMLAttributes +>(({ 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, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +}; diff --git a/src/database/schema/user.schema.ts b/src/database/schema/user.schema.ts index 400c75f..c0726f3 100644 --- a/src/database/schema/user.schema.ts +++ b/src/database/schema/user.schema.ts @@ -12,7 +12,7 @@ export const user = pgTable('users', { accountType: accountTypeEnum('account_type').notNull(), accountId: text('account_id').unique().notNull(), email: text('email').unique().notNull(), - role: uuid('role') + roleId: uuid('role_id') .notNull() .references(() => role.id), createdAt: timestamp('created_at').defaultNow().notNull(), diff --git a/src/database/seed.ts b/src/database/seed.ts index 2b11750..c2072a5 100644 --- a/src/database/seed.ts +++ b/src/database/seed.ts @@ -31,14 +31,14 @@ async function main() { email: 'jokowi@indonesia.go.id', name: 'Joko Widodo', accountType: 'google', - role: adminRole.id, + roleId: adminRole.id, }, { accountId: 'github:1234567890', email: 'prabowo@indonesia.go.id', name: 'Prabowo Subianto', accountType: 'github', - role: userRole.id, + roleId: userRole.id, }, ]) .onConflictDoNothing() diff --git a/src/lib/auth/account.ts b/src/lib/auth/account.ts index 07656ff..677d456 100644 --- a/src/lib/auth/account.ts +++ b/src/lib/auth/account.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; -import { database, role, user } from '@/database'; +import { database, role, User, user } from '@/database'; export type CreateUserProps = { accountId: string; @@ -26,7 +26,7 @@ export async function createUser( accountType: type, accountId: data.accountId, email: data.email, - role: userRole.id, + roleId: userRole.id, name: data.name, avatar: data.avatar, }) @@ -45,16 +45,14 @@ export async function getUserByUserId(userId: string) { export async function createUserViaGithub( data: CreateUserProps, -): Promise { - const userCreated = await createUser(data, 'github'); - return userCreated.id; +): Promise { + return await createUser(data, 'github'); } export async function createUserViaGoogle( data: CreateUserProps, -): Promise { - const userCreated = await createUser(data, 'google'); - return userCreated.id; +): Promise { + return await createUser(data, 'google'); } export async function getUserByGoogleId(googleId: string) { diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index a4f707b..d5ec15f 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -20,7 +20,8 @@ export const lucia = new Lucia(adapter, { getUserAttributes: (attribute) => { return { id: attribute.id, - role: attribute.role, + role: attribute.roleId, + name: attribute.name, }; }, }); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index d02abd9..0da6e15 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,4 +1,4 @@ -import { UserId } from 'lucia'; +import { eq } from 'drizzle-orm'; import { cookies } from 'next/headers'; import { cache } from 'react'; import 'server-only'; @@ -6,12 +6,14 @@ import 'server-only'; import { lucia, validateRequest } from '@/lib/auth'; import { AuthenticationError } from '@/lib/error'; +import { database, role, User } from '@/database'; + export const getCurrentUser = cache(async () => { - const session = await validateRequest(); - if (!session.user) { + const { user } = await validateRequest(); + if (!user) { return undefined; } - return session.user; + return user; }); export const assertAuthenticated = async () => { @@ -22,8 +24,25 @@ export const assertAuthenticated = async () => { return user; }; -export async function setSession(userId: UserId) { - const session = await lucia.createSession(userId, { +export const checkRole = 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; +}; + +export async function setSession(user: User) { + const session = await lucia.createSession(user.id, { expireIn: 60 * 60 * 24 * 30, }); const sessionCookie = lucia.createSessionCookie(session.id); diff --git a/src/lib/error.ts b/src/lib/error.ts index 19a7bc2..d45572e 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -1,9 +1,35 @@ export const AUTHENTICATION_ERROR_MESSAGE = 'You must be authenticated to access this resource.'; +export const AUTHORIZATION_ERROR_MESSAGE = + 'You are not authorized to access this resource.'; + export const AuthenticationError = class AuthenticationError extends Error { constructor() { super(AUTHENTICATION_ERROR_MESSAGE); this.name = 'AuthenticationError'; } }; + +export const AuthorizationError = class AuthorizationError extends Error { + constructor() { + super(AUTHORIZATION_ERROR_MESSAGE); + this.name = 'AuthorizationError'; + } +}; + +export function getStatusCode(error: Error) { + if ( + error instanceof AuthenticationError || + error.message === AUTHENTICATION_ERROR_MESSAGE + ) { + return 401; + } + if ( + error instanceof AuthorizationError || + error.message === AUTHORIZATION_ERROR_MESSAGE + ) { + return 403; + } + return 500; +}