Skip to content

Commit

Permalink
feat: management dashboard (#699)
Browse files Browse the repository at this point in the history
zekhoi authored Aug 26, 2024
1 parent fb2e663 commit b8da50c
Showing 20 changed files with 824 additions and 51 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions src/app/(home)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex flex-col min-h-screen bg-white text-gray-800'>
<Navbar isSigned={!!user.session} />
<Header isSigned={!!user.session} />
{children}
<Footer />
</div>
380 changes: 380 additions & 0 deletions src/app/(private)/dashboard/page.tsx

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions src/app/(private)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex flex-col min-h-screen bg-white text-gray-800'>
<PrivateHeader />
{children}
</div>
);
}
6 changes: 3 additions & 3 deletions src/app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -73,7 +73,7 @@ export async function GET(request: Request): Promise<Response> {

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<Response> {
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: {
6 changes: 3 additions & 3 deletions src/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
@@ -44,7 +44,7 @@ export async function GET(request: Request): Promise<Response> {
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<Response> {
});
}

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: {
10 changes: 5 additions & 5 deletions src/app/auth/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='flex flex-col min-h-screen bg-white text-gray-800'>
<Navbar isSigned={!!user.session} />
<Header isSigned={!!session} />
{children}
<Footer />
</div>
31 changes: 14 additions & 17 deletions src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Head>
<title>
{statusCode
? `${statusCode} - Error Occurred`
: 'An Error Occurred'}
</title>
<title>{errorTitle}</title>
</Head>
<main className='flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-4 overflow-hidden'>
<motion.div
@@ -36,17 +40,15 @@ function Error({ statusCode }: ErrorProps) {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
Cosmic Glitch Detected
{errorTitle}
</motion.h1>
<motion.p
className='text-xl mb-8 text-center max-w-md'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
{statusCode
? `Houston, we've encountered a ${statusCode} anomaly.`
: 'An unexpected error has warped our space-time continuum.'}
{errorDescription}
</motion.p>
<Link href='/' passHref>
<motion.div
@@ -74,9 +76,4 @@ function Error({ statusCode }: ErrorProps) {
);
}

Error.getInitialProps = ({ res, err }: NextPageContext) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { statusCode };
};

export default Error;
120 changes: 120 additions & 0 deletions src/components/private/header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className='sticky top-0 flex h-16 items-center justify-between border-b bg-background px-4 md:px-6'>
<nav className='hidden flex-col gap-6 text-lg font-medium md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6'>
<Link
href='/dashboard'
className='flex items-center gap-2 text-lg font-semibold md:text-base'
>
<Sparkles className='h-6 w-6' />
<span className='sr-only'>Sainseni</span>
</Link>
{navbarMenu.map((item) => (
<Link
key={item.url}
href={item.url}
className={`transition-colors hover:text-foreground ${
isActive(item.url)
? 'text-foreground font-semibold'
: 'text-muted-foreground'
}`}
>
{item.name}
</Link>
))}
</nav>
<Sheet>
<SheetTrigger asChild>
<Button
variant='outline'
size='icon'
className='shrink-0 md:hidden'
>
<Menu className='h-5 w-5' />
<span className='sr-only'>Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side='left'>
<nav className='grid gap-6 text-lg font-medium'>
<Link
href='/dashboard'
className='flex items-center gap-2 text-lg font-semibold'
>
<Sparkles className='h-6 w-6' />
<span className='sr-only'>Sainseni</span>
</Link>
{navbarMenu.map((item) => (
<Link
key={item.url}
href={item.url}
className={`transition-colors hover:text-foreground ${
isActive(item.url)
? 'text-foreground font-semibold'
: 'text-muted-foreground'
}`}
>
{item.name}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='secondary'
size='icon'
className='rounded-full'
>
<CircleUser className='h-5 w-5' />
<span className='sr-only'>User menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>My Name</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
);
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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 = {
50 changes: 50 additions & 0 deletions src/components/ui/avatar.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;

const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;

const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;

export { Avatar, AvatarFallback, AvatarImage };
120 changes: 120 additions & 0 deletions src/components/ui/table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as React from 'react';

import { cn } from '@/lib/utils';

const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';

const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';

const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';

const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';

const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';

const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';

const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';

const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';

export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
2 changes: 1 addition & 1 deletion src/database/schema/user.schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
4 changes: 2 additions & 2 deletions src/database/seed.ts
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 6 additions & 8 deletions src/lib/auth/account.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const userCreated = await createUser(data, 'github');
return userCreated.id;
): Promise<User> {
return await createUser(data, 'github');
}

export async function createUserViaGoogle(
data: CreateUserProps,
): Promise<string> {
const userCreated = await createUser(data, 'google');
return userCreated.id;
): Promise<User> {
return await createUser(data, 'google');
}

export async function getUserByGoogleId(googleId: string) {
3 changes: 2 additions & 1 deletion src/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
},
});
31 changes: 25 additions & 6 deletions src/lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { UserId } from 'lucia';
import { eq } from 'drizzle-orm';
import { cookies } from 'next/headers';
import { cache } from 'react';
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);
26 changes: 26 additions & 0 deletions src/lib/error.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit b8da50c

Please sign in to comment.