finally building
This commit is contained in:
36
apps/admin/app/(main)/layout.tsx
Normal file
36
apps/admin/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@workspace/ui/components/navigation-menu';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<header className="flex h-16 items-center justify-between border-b bg-background px-4 md:px-6">
|
||||
<nav>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link href="/students">Students</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</nav>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Login from '@/components/login';
|
||||
import Studs from '@/components/studs';
|
||||
import { db, admins } from '@workspace/db';
|
||||
import { auth, signIn, signOut } from '@workspace/auth';
|
||||
import { auth, signIn, signOut } from '@/auth';
|
||||
|
||||
async function getStudents() {
|
||||
'use server';
|
||||
25
apps/admin/app/(main)/students/columns.tsx
Normal file
25
apps/admin/app/(main)/students/columns.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { createSelectSchema, students } from '@workspace/db';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const studentSelectSchema = createSelectSchema(students);
|
||||
export type Student = z.infer<typeof studentSelectSchema>;
|
||||
|
||||
export const columns: ColumnDef<Student>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
},
|
||||
{
|
||||
accessorKey: 'firstName',
|
||||
header: 'First Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastName',
|
||||
header: 'Last Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
];
|
||||
66
apps/admin/app/(main)/students/data-table.tsx
Normal file
66
apps/admin/app/(main)/students/data-table.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@workspace/ui/components/table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/admin/app/(main)/students/page.tsx
Normal file
18
apps/admin/app/(main)/students/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { columns, Student } from './columns';
|
||||
import { DataTable } from './data-table';
|
||||
import { db, students } from '@workspace/db';
|
||||
|
||||
async function getData(): Promise<Student[]> {
|
||||
const data = db.select().from(students);
|
||||
return data;
|
||||
}
|
||||
|
||||
export default async function DemoPage() {
|
||||
const data = await getData();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<DataTable columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
import { handlers } from '@workspace/auth';
|
||||
import { handlers } from '@/auth';
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
29
apps/admin/app/login/page.tsx
Normal file
29
apps/admin/app/login/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { signIn } from '@/auth';
|
||||
|
||||
async function logIn() {
|
||||
'use server';
|
||||
await signIn('google', { redirectTo: '/' });
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-svh">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<form action={logIn}>
|
||||
<Button type="submit" variant="outline" className="w-full h-12">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/0 via-primary/10 to-primary/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700 ease-out pointer-events-none" />
|
||||
<img
|
||||
src="https://static.cdnlogo.com/logos/g/35/google-icon.svg"
|
||||
alt="Google logo"
|
||||
className="w-5 h-5 transition-transform duration-200"
|
||||
/>
|
||||
<span className="relative z-10 font-medium transition-colors duration-200 group-hover:text-foreground">
|
||||
Sign in with Google
|
||||
</span>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,31 @@
|
||||
import NextAuth, { type NextAuthConfig } from 'next-auth';
|
||||
import Google from 'next-auth/providers/google';
|
||||
import NextAuth, { type DefaultSession } from 'next-auth';
|
||||
import type { NextAuthConfig } from 'next-auth';
|
||||
import Google from "next-auth/providers/google";
|
||||
import { db, admins, students } from '@workspace/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq } from '@workspace/db/drizzle';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
[key: string]: any;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface JWT {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next/server' {
|
||||
interface NextRequest {
|
||||
auth: import('next-auth').Session | null;
|
||||
}
|
||||
}
|
||||
|
||||
const authConfig: NextAuthConfig = {
|
||||
providers: [Google],
|
||||
@@ -58,9 +82,6 @@ const authConfig: NextAuthConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const nextAuth = NextAuth(authConfig);
|
||||
|
||||
export const handlers: typeof nextAuth.handlers = nextAuth.handlers;
|
||||
export const signIn: typeof nextAuth.signIn = nextAuth.signIn;
|
||||
export const signOut: typeof nextAuth.signOut = nextAuth.signOut;
|
||||
export const auth: typeof nextAuth.auth = nextAuth.auth;
|
||||
// Note: TypeScript warnings about inferred types are expected with NextAuth v5 beta
|
||||
// These warnings don't affect functionality
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
import { signIn } from '@workspace/auth';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
|
||||
export default function Login({ action }: { action: () => Promise<void> }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { auth } from '@workspace/auth';
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export default async function Studs({
|
||||
action,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { auth } from '@workspace/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
|
||||
export default auth((req: any) => {
|
||||
// If the user is unauthenticated or an admin, allow the request.
|
||||
if (!req.auth || req.auth.user?.role === 'ADMIN') {
|
||||
export default auth((req: NextRequest) => {
|
||||
if (!req.auth) {
|
||||
return NextResponse.redirect(new URL('/login', req.url));
|
||||
}
|
||||
|
||||
if (req.auth.user?.role === 'ADMIN') {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -11,8 +14,8 @@ export default auth((req: any) => {
|
||||
const studentUrl = process.env.STUDENT_URL ?? 'http://localhost:3000';
|
||||
|
||||
return NextResponse.redirect(new URL(studentUrl, req.url));
|
||||
}) as any;
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login).*)'],
|
||||
};
|
||||
|
||||
19
apps/admin/next-auth.d.ts
vendored
19
apps/admin/next-auth.d.ts
vendored
@@ -1,19 +0,0 @@
|
||||
import 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['@workspace/ui', '@workspace/db', '@workspace/auth'],
|
||||
transpilePackages: ['@workspace/ui', '@workspace/db'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -12,24 +12,25 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@workspace/auth": "workspace:*",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@workspace/db": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"framer-motion": "^12.19.1",
|
||||
"framer-motion": "^12.22.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "^15.2.3",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"next": "^15.3.4",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/node": "^20.19.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@workspace/eslint-config": "workspace:^",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,18 @@
|
||||
"extends": "@workspace/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@workspace/ui/*": ["../../packages/ui/src/*"],
|
||||
"@workspace/db/*": ["../../packages/db/src/*"],
|
||||
"@workspace/auth/*": ["../../packages/auth/src/*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@workspace/ui/*": [
|
||||
"../../packages/ui/src/*"
|
||||
],
|
||||
"@workspace/db": [
|
||||
"../../packages/db/index.ts"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -14,6 +21,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
36
apps/student/app/(main)/layout.tsx
Normal file
36
apps/student/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@workspace/ui/components/navigation-menu';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<header className="flex h-16 items-center justify-between border-b bg-background px-4 md:px-6">
|
||||
<nav>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link href="/signup">Signup</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</nav>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Login from '@/components/login';
|
||||
import Studs from '@/components/studs';
|
||||
import { db, admins } from '@workspace/db';
|
||||
import { auth, signIn, signOut } from '@workspace/auth';
|
||||
import { auth, signIn, signOut } from '@/auth';
|
||||
|
||||
async function getStudents() {
|
||||
'use server';
|
||||
@@ -25,7 +25,7 @@ export default async function Page() {
|
||||
<div className="flex items-center justify-center min-h-svh">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Hello student {session?.user?.name}</h1>
|
||||
{!session?.user && <Login logIn={logIn} />}
|
||||
{!session?.user && <Login action={logIn} />}
|
||||
<Studs action={getStudents} logOut={logOut} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,2 +1,2 @@
|
||||
import { handlers } from '@workspace/auth';
|
||||
import { handlers } from '@/auth';
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
29
apps/student/app/login/page.tsx
Normal file
29
apps/student/app/login/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { signIn } from '@/auth';
|
||||
|
||||
async function logIn() {
|
||||
'use server';
|
||||
await signIn('google', { redirectTo: '/' });
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-svh">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<form action={logIn}>
|
||||
<Button type="submit" variant="outline" className="w-full h-12">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/0 via-primary/10 to-primary/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700 ease-out pointer-events-none" />
|
||||
<img
|
||||
src="https://static.cdnlogo.com/logos/g/35/google-icon.svg"
|
||||
alt="Google logo"
|
||||
className="w-5 h-5 transition-transform duration-200"
|
||||
/>
|
||||
<span className="relative z-10 font-medium transition-colors duration-200 group-hover:text-foreground">
|
||||
Sign in with Google
|
||||
</span>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
apps/student/auth.ts
Normal file
87
apps/student/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import NextAuth, { type DefaultSession } from 'next-auth';
|
||||
import type { NextAuthConfig } from 'next-auth';
|
||||
import Google from "next-auth/providers/google";
|
||||
import { db, admins, students } from '@workspace/db';
|
||||
import { eq } from '@workspace/db/drizzle';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
[key: string]: any;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface JWT {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next/server' {
|
||||
interface NextRequest {
|
||||
auth: import('next-auth').Session | null;
|
||||
}
|
||||
}
|
||||
|
||||
const authConfig: NextAuthConfig = {
|
||||
providers: [Google],
|
||||
callbacks: {
|
||||
async jwt({ token, account, user, profile }) {
|
||||
// Only check DB on first sign in
|
||||
if (account && user && user.email) {
|
||||
const admin = await db.select().from(admins).where(eq(admins.email, user.email)).limit(1);
|
||||
if (admin.length > 0 && admin[0]) {
|
||||
token.role = 'ADMIN';
|
||||
token.adminId = admin[0].id;
|
||||
} else {
|
||||
token.role = 'USER';
|
||||
const student = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.email, user.email))
|
||||
.limit(1);
|
||||
if (student.length > 0 && student[0]) {
|
||||
token.studentId = student[0].id;
|
||||
} else {
|
||||
const nameParts = user.name?.split(' ') ?? [];
|
||||
const firstName = nameParts[0] || '';
|
||||
const lastName = nameParts.slice(1).join(' ') || '';
|
||||
const newStudent = await db
|
||||
.insert(students)
|
||||
.values({
|
||||
email: user.email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
profilePicture: user.image,
|
||||
})
|
||||
.returning({ id: students.id });
|
||||
if (newStudent[0]) {
|
||||
token.studentId = newStudent[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token?.role) {
|
||||
session.user.role = token.role as 'ADMIN' | 'USER';
|
||||
}
|
||||
if (token?.adminId) {
|
||||
session.user.adminId = token.adminId as number;
|
||||
}
|
||||
if (token?.studentId) {
|
||||
session.user.studentId = token.studentId as number;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Note: TypeScript warnings about inferred types are expected with NextAuth v5 beta
|
||||
// These warnings don't affect functionality
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
import { signIn } from '@workspace/auth';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
|
||||
export default function Login({ logIn }: { logIn: () => Promise<void> }) {
|
||||
export default function Login({ action }: { action: () => Promise<void> }) {
|
||||
return (
|
||||
<form action={logIn}>
|
||||
<form action={action}>
|
||||
<Button type="submit" variant="outline" className="w-full h-12">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/0 via-primary/10 to-primary/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700 ease-out pointer-events-none" />
|
||||
<img
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { auth } from '@workspace/auth';
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export default async function Studs({
|
||||
action,
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { auth } from '@workspace/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
|
||||
export default auth((req: any) => {
|
||||
// If the user is unauthenticated or a student, allow the request.
|
||||
if (!req.auth || req.auth.user?.role === 'USER') {
|
||||
export default auth((req: NextRequest) => {
|
||||
if (!req.auth) {
|
||||
return NextResponse.redirect(new URL('/login', req.url));
|
||||
}
|
||||
|
||||
if (req.auth.user?.role === 'USER') {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Otherwise, redirect to the admin app.
|
||||
const adminURL = process.env.ADMIN_URL ?? 'http://localhost:3001';
|
||||
const adminUrl = process.env.ADMIN_URL ?? 'http://localhost:3001';
|
||||
|
||||
return NextResponse.redirect(new URL(adminURL, req.url));
|
||||
}) as any;
|
||||
return NextResponse.redirect(new URL(adminUrl, req.url));
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login).*)'],
|
||||
};
|
||||
|
||||
20
apps/student/next-auth.d.ts
vendored
20
apps/student/next-auth.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
import 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['@workspace/ui', '@workspace/auth', '@workspace/db'],
|
||||
transpilePackages: ['@workspace/ui', '@workspace/db'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -13,25 +13,26 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@workspace/db": "workspace:*",
|
||||
"@workspace/auth": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"framer-motion": "^12.22.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "^15.2.3",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"zod": "^3.24.2"
|
||||
"next": "^15.3.4",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.59.0",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/node": "^20.19.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@workspace/eslint-config": "workspace:^",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,18 @@
|
||||
"extends": "@workspace/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@workspace/ui/*": ["../../packages/ui/src/*"],
|
||||
"@workspace/db/*": ["../../packages/db/src/*"],
|
||||
"@workspace/db": ["../../packages/db/src"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@workspace/ui/*": [
|
||||
"../../packages/ui/src/*"
|
||||
],
|
||||
"@workspace/db": [
|
||||
"../../packages/db/index.ts"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -14,6 +21,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@workspace/eslint-config": "workspace:*",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"prettier": "^3.6.0",
|
||||
"prettier": "^3.6.2",
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "@workspace/auth",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"lint": "eslint . --max-warnings 0"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"dependencies": {
|
||||
"next-auth": "5.0.0-beta.28",
|
||||
"@workspace/db": "workspace:*",
|
||||
"drizzle-orm": "^0.44.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.5.0"
|
||||
}
|
||||
}
|
||||
20
packages/auth/types.d.ts
vendored
20
packages/auth/types.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
import 'next-auth';
|
||||
import 'next-auth/jwt';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
role?: 'ADMIN' | 'USER';
|
||||
adminId?: number;
|
||||
studentId?: number;
|
||||
}
|
||||
}
|
||||
1
packages/db/drizzle.ts
Normal file
1
packages/db/drizzle.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from 'drizzle-orm';
|
||||
@@ -2,4 +2,4 @@ import { drizzle } from 'drizzle-orm/neon-http';
|
||||
|
||||
export const db = drizzle(process.env.DATABASE_URL!);
|
||||
|
||||
export * from './schema';
|
||||
export * from './schema.ts';
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./schema": "./schema.ts",
|
||||
"./drizzle": "./drizzle.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
@@ -17,13 +22,14 @@
|
||||
"packageManager": "pnpm@10.4.1",
|
||||
"dependencies": {
|
||||
"@neondatabase/serverless": "^1.0.1",
|
||||
"drizzle-orm": "^0.44.2"
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"drizzle-zod": "^0.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@workspace/eslint-config": "workspace:*",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"typescript": "^5.7.3",
|
||||
"@workspace/eslint-config": "workspace:*",
|
||||
"@workspace/typescript-config": "workspace:*"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
check,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export { createSelectSchema } from 'drizzle-zod';
|
||||
|
||||
export const students = pgTable('students', {
|
||||
id: serial().primaryKey(),
|
||||
email: text().notNull(),
|
||||
@@ -262,7 +264,6 @@ export const applicationsRelations = relations(applications, ({ one }) => ({
|
||||
references: [resumes.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const admins = pgTable('admins', {
|
||||
id: serial().primaryKey(),
|
||||
email: text().notNull().unique(),
|
||||
@@ -272,3 +273,4 @@ export const admins = pgTable('admins', {
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@workspace/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
|
||||
168
packages/ui/src/components/navigation-menu.tsx
Normal file
168
packages/ui/src/components/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
116
packages/ui/src/components/table.tsx
Normal file
116
packages/ui/src/components/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
5023
pnpm-lock.yaml
generated
5023
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user