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 (
+
+ );
+}
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;
+}