diff --git a/App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts b/App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts new file mode 100644 index 0000000..7ddbc82 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/superuser-requests/actions.ts @@ -0,0 +1,71 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { revalidatePath } from "next/cache"; + +type Result = { ok: true } | { error: string }; + +export async function resolveRequest( + requestId: string, + decision: "APPROVED" | "DENIED" +): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_users")) { + return { error: "Unauthorized" }; + } + + const request = await db.superUserRequest.findUnique({ + where: { id: requestId }, + include: { user: true }, + }); + if (!request) return { error: "Request not found" }; + if (request.status !== "PENDING") return { error: "Request has already been resolved" }; + + await db.$transaction(async (tx) => { + await tx.superUserRequest.update({ + where: { id: requestId }, + data: { + status: decision, + resolvedAt: new Date(), + resolvedById: session.user.id, + }, + }); + + if (decision === "APPROVED") { + await tx.user.update({ + where: { id: request.userId }, + data: { role: "SUPERUSER" }, + }); + } + }); + + revalidatePath("/admin/superuser-requests"); + revalidatePath("/admin/users"); + return { ok: true }; +} + +export async function grantSuperUser(userId: string): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_users")) { + return { error: "Unauthorized" }; + } + + const user = await db.user.findUnique({ where: { id: userId }, select: { role: true, name: true } }); + if (!user) return { error: "User not found" }; + if (user.role === "SUPERUSER") return { error: "User is already a SuperUser" }; + if (user.role === "ADMIN") return { error: "Cannot change Admin role" }; + + await db.user.update({ where: { id: userId }, data: { role: "SUPERUSER" } }); + + // Auto-close any pending request for this user + await db.superUserRequest.updateMany({ + where: { userId, status: "PENDING" }, + data: { status: "APPROVED", resolvedAt: new Date(), resolvedById: session.user.id }, + }); + + revalidatePath("/admin/users"); + revalidatePath("/admin/superuser-requests"); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx b/App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx new file mode 100644 index 0000000..23251ff --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/superuser-requests/page.tsx @@ -0,0 +1,135 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { redirect } from "next/navigation"; +import { formatDate } from "@/lib/utils"; +import { RequestActions } from "./request-actions"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "SuperUser Requests" }; + +const STATUS_STYLES: Record = { + PENDING: "bg-warning-50 text-warning-700", + APPROVED: "bg-success-50 text-success-700", + DENIED: "bg-danger-50 text-danger-700", +}; + +const ROLE_LABELS: Record = { + TECHNICAL: "Technical", + MANNING: "Manning", + ACCOUNTS: "Accounts", + MANAGER: "Manager", + SUPERUSER: "SuperUser", + AUDITOR: "Auditor", + ADMIN: "Admin", +}; + +export default async function SuperUserRequestsPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_users")) redirect("/dashboard"); + + const requests = await db.superUserRequest.findMany({ + include: { + user: { select: { id: true, name: true, email: true, employeeId: true, role: true } }, + resolvedBy: { select: { name: true } }, + }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + }); + + const pending = requests.filter((r) => r.status === "PENDING"); + const resolved = requests.filter((r) => r.status !== "PENDING"); + + return ( +
+
+
+

SuperUser Access Requests

+

+ {pending.length} pending · {resolved.length} resolved +

+
+
+ + {requests.length === 0 && ( +
+ No SuperUser access requests. +
+ )} + + {pending.length > 0 && ( +
+

Pending

+
+ {pending.map((req) => ( +
+
+
+
+ {req.user.name} + {req.user.employeeId} + + {ROLE_LABELS[req.user.role] ?? req.user.role} + +
+

{req.user.email}

+ {req.reason && ( +

+ “{req.reason}” +

+ )} +

+ Requested {formatDate(req.createdAt)} +

+
+ +
+
+ ))} +
+
+ )} + + {resolved.length > 0 && ( +
+

Resolved

+
+ + + + + + + + + + + + {resolved.map((req) => ( + + + + + + + + ))} + +
UserCurrent RoleStatusResolved ByResolved
+
{req.user.name}
+
{req.user.email}
+
+ {ROLE_LABELS[req.user.role] ?? req.user.role} + + + {req.status.charAt(0) + req.status.slice(1).toLowerCase()} + + {req.resolvedBy?.name ?? "—"} + {req.resolvedAt ? formatDate(req.resolvedAt) : "—"} +
+
+
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx b/App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx new file mode 100644 index 0000000..0d94df8 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/superuser-requests/request-actions.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { resolveRequest } from "./actions"; +import { Check, X } from "lucide-react"; + +export function RequestActions({ requestId }: { requestId: string }) { + const router = useRouter(); + const [pending, setPending] = useState<"approve" | "deny" | null>(null); + const [error, setError] = useState(""); + + async function handle(decision: "APPROVED" | "DENIED") { + setPending(decision === "APPROVED" ? "approve" : "deny"); + setError(""); + const result = await resolveRequest(requestId, decision); + setPending(null); + if ("error" in result) setError(result.error); + else router.refresh(); + } + + return ( +
+ {error && {error}} + + +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx b/App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx new file mode 100644 index 0000000..4ea025e --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/users/grant-superuser-button.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { grantSuperUser } from "../superuser-requests/actions"; +import { ShieldCheck } from "lucide-react"; + +export function GrantSuperUserButton({ userId }: { userId: string }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function handle() { + if (!confirm("Grant this user SuperUser access?")) return; + setPending(true); + setError(""); + const result = await grantSuperUser(userId); + setPending(false); + if ("error" in result) setError(result.error); + else router.refresh(); + } + + return ( + + + + ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/users/page.tsx b/App/pelagia-portal/app/(portal)/admin/users/page.tsx index 2666c3a..6bf7da3 100644 --- a/App/pelagia-portal/app/(portal)/admin/users/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/users/page.tsx @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { formatDate } from "@/lib/utils"; import { AddUserButton, EditUserButton } from "./user-form"; import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; +import { GrantSuperUserButton } from "./grant-superuser-button"; import { deleteUser } from "./actions"; import type { Metadata } from "next"; @@ -68,7 +69,10 @@ export default async function AdminUsersPage() { {formatDate(user.createdAt)} - + + {user.role !== "SUPERUSER" && user.role !== "ADMIN" && ( + + )}
- + {hasSignature ? ( + + ) : ( +
+ +
+

Signature required to approve POs

+

+ You must upload your approval signature before you can approve, reject, or request edits on purchase orders. +

+ + Go to Profile to upload your signature → + +
+
+ )}
); diff --git a/App/pelagia-portal/app/(portal)/profile/actions.ts b/App/pelagia-portal/app/(portal)/profile/actions.ts new file mode 100644 index 0000000..c34c0f3 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/actions.ts @@ -0,0 +1,125 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { buildSignatureKey, uploadBuffer } from "@/lib/storage"; +import { revalidatePath } from "next/cache"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; + +type Result = { ok: true } | { error: string }; + +// ── Change password ─────────────────────────────────────────────────────────── + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(8, "New password must be at least 8 characters"), +}); + +export async function changePassword(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const parsed = changePasswordSchema.safeParse({ + currentPassword: formData.get("currentPassword"), + newPassword: formData.get("newPassword"), + }); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { passwordHash: true }, + }); + if (!user) return { error: "User not found" }; + + const valid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); + if (!valid) return { error: "Current password is incorrect" }; + + const newHash = await bcrypt.hash(parsed.data.newPassword, 12); + await db.user.update({ + where: { id: session.user.id }, + data: { passwordHash: newHash }, + }); + + return { ok: true }; +} + +// ── Upload signature ────────────────────────────────────────────────────────── + +export async function saveSignature(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + if (session.user.role !== "MANAGER" && session.user.role !== "SUPERUSER") { + return { error: "Only managers and superusers can upload a signature" }; + } + + const file = formData.get("signature") as File | null; + if (!file || file.size === 0) return { error: "No file provided" }; + if (file.size > 2 * 1024 * 1024) return { error: "Signature must be under 2 MB" }; + + const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; + if (!allowed.includes(file.type)) { + return { error: "Signature must be a PNG, JPG, or WebP image" }; + } + + const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"; + const key = buildSignatureKey(session.user.id, ext); + const buffer = Buffer.from(await file.arrayBuffer()); + + await uploadBuffer(key, buffer, file.type); + + await db.user.update({ + where: { id: session.user.id }, + data: { signatureKey: key }, + }); + + revalidatePath("/profile"); + revalidatePath("/approvals"); + return { ok: true }; +} + +// ── Remove signature ────────────────────────────────────────────────────────── + +export async function removeSignature(): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + await db.user.update({ + where: { id: session.user.id }, + data: { signatureKey: null }, + }); + + revalidatePath("/profile"); + revalidatePath("/approvals"); + return { ok: true }; +} + +// ── Request SuperUser access ────────────────────────────────────────────────── + +export async function requestSuperUser(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + if (session.user.role === "SUPERUSER" || session.user.role === "ADMIN") { + return { error: "You already have elevated access" }; + } + + const reason = (formData.get("reason") as string | null)?.trim() ?? ""; + + // Check for an existing pending request + const existing = await db.superUserRequest.findFirst({ + where: { userId: session.user.id, status: "PENDING" }, + }); + if (existing) return { error: "You already have a pending SuperUser request" }; + + await db.superUserRequest.create({ + data: { + userId: session.user.id, + reason: reason || null, + }, + }); + + revalidatePath("/profile"); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/profile/change-password-form.tsx b/App/pelagia-portal/app/(portal)/profile/change-password-form.tsx new file mode 100644 index 0000000..f15dd52 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/change-password-form.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { changePassword } from "./actions"; + +export function ChangePasswordForm() { + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const fd = new FormData(form); + + const newPw = fd.get("newPassword") as string; + const confirmPw = fd.get("confirmPassword") as string; + if (newPw !== confirmPw) { + setError("New passwords do not match"); + return; + } + + setPending(true); + setSuccess(false); + setError(""); + const result = await changePassword(fd); + setPending(false); + + if ("error" in result) { + setError(result.error); + } else { + setSuccess(true); + form.reset(); + } + } + + return ( +
+
+ + +
+
+ + +
+
+ + +
+ + {error && ( +

{error}

+ )} + {success && ( +

+ Password changed successfully. +

+ )} + + +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/profile/page.tsx b/App/pelagia-portal/app/(portal)/profile/page.tsx new file mode 100644 index 0000000..c82ee1d --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/page.tsx @@ -0,0 +1,120 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { redirect } from "next/navigation"; +import { generateDownloadUrl } from "@/lib/storage"; +import { ChangePasswordForm } from "./change-password-form"; +import { SignatureUploader } from "./signature-uploader"; +import { SuperUserRequestForm } from "./superuser-request-form"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "My Profile" }; + +const ROLE_LABELS: Record = { + TECHNICAL: "Technical", + MANNING: "Manning", + ACCOUNTS: "Accounts", + MANAGER: "Manager", + SUPERUSER: "SuperUser", + AUDITOR: "Auditor", + ADMIN: "Admin", +}; + +export default async function ProfilePage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { + id: true, + name: true, + email: true, + employeeId: true, + role: true, + signatureKey: true, + superUserRequests: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { status: true, createdAt: true }, + }, + }, + }); + if (!user) redirect("/login"); + + const canHaveSignature = user.role === "MANAGER" || user.role === "SUPERUSER"; + const canRequestSuperUser = user.role !== "SUPERUSER" && user.role !== "ADMIN"; + + const signatureUrl = user.signatureKey + ? await generateDownloadUrl(user.signatureKey) + : null; + + const latestRequest = user.superUserRequests[0] ?? null; + + return ( +
+
+

My Profile

+

Manage your account settings

+
+ + {/* Account Info */} +
+

Account Information

+
+
+
Name
+
{user.name}
+
+
+
Email
+
{user.email}
+
+
+
Employee ID
+
{user.employeeId}
+
+
+
Role
+
+ + {ROLE_LABELS[user.role] ?? user.role} + +
+
+
+
+ + {/* Change Password */} +
+

Change Password

+ +
+ + {/* Signature (managers & superusers) */} + {canHaveSignature && ( +
+
+

Approval Signature

+

+ Your signature is embedded in approved PO documents (PDF and XLSX). + {!user.signatureKey && ( + + A signature is required to approve purchase orders. + + )} +

+
+ +
+ )} + + {/* SuperUser access request */} + {canRequestSuperUser && ( +
+

SuperUser Access

+ +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx b/App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx new file mode 100644 index 0000000..d8dfa48 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useRef, useState } from "react"; +import { saveSignature, removeSignature } from "./actions"; +import { Upload, X } from "lucide-react"; + +interface Props { + currentSignatureUrl: string | null; +} + +export function SignatureUploader({ currentSignatureUrl }: Props) { + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + const [pending, setPending] = useState(false); + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setError(""); + setSuccess(""); + setPreview(URL.createObjectURL(file)); + } + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + const file = inputRef.current?.files?.[0]; + if (!file) { setError("Please select a file first"); return; } + + const fd = new FormData(); + fd.append("signature", file); + + setPending(true); + setError(""); + setSuccess(""); + const result = await saveSignature(fd); + setPending(false); + + if ("error" in result) { + setError(result.error); + } else { + setSuccess("Signature saved successfully."); + setPreview(null); + if (inputRef.current) inputRef.current.value = ""; + } + } + + async function handleRemove() { + setRemoving(true); + setError(""); + setSuccess(""); + const result = await removeSignature(); + setRemoving(false); + if ("error" in result) setError(result.error); + else setSuccess("Signature removed."); + } + + const displayUrl = preview ?? currentSignatureUrl; + + return ( +
+ {displayUrl && ( +
+

+ {preview ? "Preview" : "Current signature"} +

+ {/* eslint-disable-next-line @next/next/no-img-element */} + Signature +
+ )} + +
+
inputRef.current?.click()} + > + +

+ Click to select signature image +

+

PNG, JPG or WebP — max 2 MB

+ +
+ + {error && ( +

{error}

+ )} + {success && ( +

{success}

+ )} + +
+ + {currentSignatureUrl && !preview && ( + + )} +
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx b/App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx new file mode 100644 index 0000000..65f920d --- /dev/null +++ b/App/pelagia-portal/app/(portal)/profile/superuser-request-form.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { requestSuperUser } from "./actions"; + +interface Props { + pendingRequest: { createdAt: Date; status: string } | null; +} + +export function SuperUserRequestForm({ pendingRequest }: Props) { + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + if (pendingRequest) { + const isPending = pendingRequest.status === "PENDING"; + const isApproved = pendingRequest.status === "APPROVED"; + return ( +
+ {isPending && "Your SuperUser access request is pending admin review."} + {isApproved && "Your SuperUser access request was approved."} + {!isPending && !isApproved && "Your SuperUser access request was not approved."} +
+ ); + } + + if (submitted) { + return ( +
+ Your request has been submitted. An admin will review it shortly. +
+ ); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); + setError(""); + const result = await requestSuperUser(new FormData(e.currentTarget)); + setPending(false); + if ("error" in result) setError(result.error); + else setSubmitted(true); + } + + return ( +
+

+ SuperUser access grants additional cross-team permissions. Describe why you need elevated access and an admin will review your request. +

+
+ +