diff --git a/App/pelagia-portal/app/(auth)/login/page.tsx b/App/pelagia-portal/app/(auth)/login/page.tsx new file mode 100644 index 0000000..fe5c80a --- /dev/null +++ b/App/pelagia-portal/app/(auth)/login/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Anchor } from "lucide-react"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError("Invalid email or password. Please try again."); + setLoading(false); + } else { + router.push(callbackUrl); + router.refresh(); + } + } + + return ( +
+
+
+
+
+ +
+
+

Pelagia Portal

+

Purchase Order Management

+
+
+ +

Sign in

+ +
+
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" + placeholder="you@company.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" + placeholder="••••••••" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+ +

+ Contact your administrator if you need access. +

+
+
+ ); +} diff --git a/App/pelagia-portal/app/api/auth/[...nextauth]/route.ts b/App/pelagia-portal/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/App/pelagia-portal/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/App/pelagia-portal/auth.ts b/App/pelagia-portal/auth.ts new file mode 100644 index 0000000..7170e34 --- /dev/null +++ b/App/pelagia-portal/auth.ts @@ -0,0 +1,61 @@ +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import bcrypt from "bcryptjs"; +import { db } from "@/lib/db"; +import { loginSchema } from "@/lib/validations/user"; +import type { Role } from "@prisma/client"; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + session: { strategy: "jwt" }, + pages: { + signIn: "/login", + error: "/login", + }, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const parsed = loginSchema.safeParse(credentials); + if (!parsed.success) return null; + + const user = await db.user.findUnique({ + where: { email: parsed.data.email }, + }); + if (!user || !user.isActive) return null; + + const valid = await bcrypt.compare(parsed.data.password, user.passwordHash); + if (!valid) return null; + + return { id: user.id, email: user.email, name: user.name, role: user.role }; + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.id = user.id; + token.role = (user as unknown as { role: Role }).role; + } + return token; + }, + session({ session, token }) { + session.user.id = token.id as string; + session.user.role = token.role as Role; + return session; + }, + }, +}); + +declare module "next-auth" { + interface Session { + user: { + id: string; + name: string; + email: string; + role: Role; + }; + } +} diff --git a/App/pelagia-portal/lib/permissions.ts b/App/pelagia-portal/lib/permissions.ts new file mode 100644 index 0000000..11e91c7 --- /dev/null +++ b/App/pelagia-portal/lib/permissions.ts @@ -0,0 +1,75 @@ +import type { Role } from "@prisma/client"; + +export type Permission = + | "create_po" + | "submit_po" + | "edit_own_draft_po" + | "view_own_pos" + | "view_all_pos" + | "approve_po" + | "reject_po" + | "request_edits" + | "request_vendor_id" + | "process_payment" + | "confirm_receipt" + | "view_analytics" + | "export_reports" + | "manage_users" + | "manage_vendors" + | "manage_vessels_accounts" + | "manage_products"; + +const ROLE_PERMISSIONS: Record = { + TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], + MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], + ACCOUNTS: ["view_all_pos", "process_payment"], + MANAGER: [ + "view_all_pos", + "approve_po", + "reject_po", + "request_edits", + "request_vendor_id", + "view_analytics", + "export_reports", + ], + SUPERUSER: [ + "create_po", + "submit_po", + "edit_own_draft_po", + "view_own_pos", + "view_all_pos", + "approve_po", + "reject_po", + "request_edits", + "request_vendor_id", + "process_payment", + "confirm_receipt", + "view_analytics", + "export_reports", + ], + AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"], + ADMIN: [ + "view_own_pos", + "view_all_pos", + "view_analytics", + "export_reports", + "manage_users", + "manage_vendors", + "manage_vessels_accounts", + "manage_products", + ], +}; + +export function hasPermission(role: Role, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; +} + +export function requirePermission(role: Role, permission: Permission): void { + if (!hasPermission(role, permission)) { + throw new Error(`Forbidden: role ${role} lacks permission ${permission}`); + } +} + +export function getPermissions(role: Role): Permission[] { + return ROLE_PERMISSIONS[role] ?? []; +} diff --git a/App/pelagia-portal/lib/validations/user.ts b/App/pelagia-portal/lib/validations/user.ts new file mode 100644 index 0000000..b9cedd4 --- /dev/null +++ b/App/pelagia-portal/lib/validations/user.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +export const createUserSchema = z.object({ + employeeId: z.string().min(1, "Employee ID is required"), + email: z.string().email("Invalid email address"), + name: z.string().min(1, "Name is required"), + password: z.string().min(8, "Password must be at least 8 characters"), + role: z.enum(["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"]), +}); + +export const updateUserSchema = createUserSchema + .omit({ password: true }) + .extend({ password: z.string().min(8).optional() }); + +export type LoginInput = z.infer; +export type CreateUserInput = z.infer; diff --git a/App/pelagia-portal/middleware.ts b/App/pelagia-portal/middleware.ts new file mode 100644 index 0000000..fa42626 --- /dev/null +++ b/App/pelagia-portal/middleware.ts @@ -0,0 +1,24 @@ +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; + +export default auth((req) => { + const isAuthenticated = !!req.auth; + const pathname = req.nextUrl.pathname; + const isLoginPage = pathname === "/login"; + + if (!isAuthenticated && !isLoginPage) { + const loginUrl = new URL("/login", req.url); + loginUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(loginUrl); + } + + if (isAuthenticated && isLoginPage) { + return NextResponse.redirect(new URL("/dashboard", req.url)); + } +}); + +export const config = { + matcher: [ + "/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +};