feat(profile): user profile page, manager signature, and SuperUser access requests

Schema (migration: 20260516103515_user_profile_signature):
- User.signatureKey String? — storage key for the manager's approval signature
- New RequestStatus enum (PENDING / APPROVED / DENIED)
- New SuperUserRequest model — tracks access-escalation requests from users

Profile page (/profile) — all roles:
- Account info panel (name, email, employee ID, role — read-only)
- Change password form (validates current password, bcrypt hash on save)
- Signature uploader (MANAGER / SUPERUSER only) — PNG/JPG/WebP up to 2 MB;
  previews before save; can remove existing signature
- SuperUser access request form — textarea for reason, shows current request
  status (pending / approved / denied) after submission

Signature gate on approval page (/approvals/[id]):
- Server checks if the current manager has uploaded a signatureKey
- If missing: shows an amber warning banner with a deep-link to /profile
  instead of rendering the approval action buttons; managers cannot
  approve, reject, or request edits without a signature on file

PDF and XLSX exports:
- Fetches the approver's signature image from storage after identifying
  the approval action
- PDF: embeds as base64 data URI <img> above the approver name in the
  left signature block
- XLSX: inserts the image into the sig-row cells via ExcelJS addImage;
  adds a name row below the image for legibility

SuperUser requests admin page (/admin/superuser-requests):
- Pending requests listed with user info, role, reason, and Approve/Deny buttons
- Approve: sets user.role = SUPERUSER and closes the request
- Deny: marks request DENIED, user role unchanged
- Resolved history table below

Admin user management updates (/admin/users):
- "SuperUser" button (ShieldCheck icon) on every non-superuser, non-admin row
- Directly grants SUPERUSER role and auto-closes any open request for that user

lib/storage.ts:
- buildSignatureKey(userId, ext) helper
- uploadBuffer(key, buffer, contentType) — server-side write to dev-uploads or R2
- downloadBuffer(key) — server-side read from dev-uploads or R2 presigned URL

Sidebar:
- "My Profile" link (UserCircle) visible to all roles
- "SuperUser Requests" link (ShieldCheck) in admin section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-16 16:09:30 +05:30
parent 3b3a26eafe
commit 3556b1425f
16 changed files with 1022 additions and 10 deletions

View file

@ -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<Result> {
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<Result> {
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 };
}

View file

@ -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<string, string> = {
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<string, string> = {
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 (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">SuperUser Access Requests</h1>
<p className="mt-1 text-sm text-neutral-500">
{pending.length} pending · {resolved.length} resolved
</p>
</div>
</div>
{requests.length === 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-12 text-center text-neutral-500">
No SuperUser access requests.
</div>
)}
{pending.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-semibold text-neutral-700 mb-3">Pending</h2>
<div className="rounded-lg border border-neutral-200 bg-white divide-y divide-neutral-100">
{pending.map((req) => (
<div key={req.id} className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-neutral-900">{req.user.name}</span>
<span className="font-mono text-xs text-neutral-400">{req.user.employeeId}</span>
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600">
{ROLE_LABELS[req.user.role] ?? req.user.role}
</span>
</div>
<p className="text-sm text-neutral-500">{req.user.email}</p>
{req.reason && (
<p className="mt-2 text-sm text-neutral-700 italic">
&ldquo;{req.reason}&rdquo;
</p>
)}
<p className="mt-1 text-xs text-neutral-400">
Requested {formatDate(req.createdAt)}
</p>
</div>
<RequestActions requestId={req.id} />
</div>
</div>
))}
</div>
</div>
)}
{resolved.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-neutral-700 mb-3">Resolved</h2>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">User</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Current Role</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Resolved By</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Resolved</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{resolved.map((req) => (
<tr key={req.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<div className="font-medium text-neutral-900">{req.user.name}</div>
<div className="text-xs text-neutral-400">{req.user.email}</div>
</td>
<td className="px-4 py-3 text-neutral-600">
{ROLE_LABELS[req.user.role] ?? req.user.role}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[req.status] ?? "bg-neutral-100 text-neutral-600"}`}>
{req.status.charAt(0) + req.status.slice(1).toLowerCase()}
</span>
</td>
<td className="px-4 py-3 text-neutral-600">{req.resolvedBy?.name ?? "—"}</td>
<td className="px-4 py-3 text-neutral-500">
{req.resolvedAt ? formatDate(req.resolvedAt) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="flex items-center gap-2 shrink-0">
{error && <span className="text-xs text-danger-700">{error}</span>}
<button
onClick={() => handle("DENIED")}
disabled={!!pending}
className="inline-flex items-center gap-1.5 rounded-lg border border-danger-200 bg-white px-3 py-1.5 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-50 transition-colors"
>
<X className="h-3.5 w-3.5" />
{pending === "deny" ? "Denying…" : "Deny"}
</button>
<button
onClick={() => handle("APPROVED")}
disabled={!!pending}
className="inline-flex items-center gap-1.5 rounded-lg bg-success px-3 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
<Check className="h-3.5 w-3.5" />
{pending === "approve" ? "Approving…" : "Approve"}
</button>
</div>
);
}

View file

@ -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 (
<span title={error || undefined}>
<button
onClick={handle}
disabled={pending}
title="Grant SuperUser access"
className="inline-flex items-center gap-1 rounded border border-primary-200 bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50 transition-colors"
>
<ShieldCheck className="h-3 w-3" />
{pending ? "…" : "SuperUser"}
</button>
</span>
);
}

View file

@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { AddUserButton, EditUserButton } from "./user-form"; import { AddUserButton, EditUserButton } from "./user-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { GrantSuperUserButton } from "./grant-superuser-button";
import { deleteUser } from "./actions"; import { deleteUser } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -68,7 +69,10 @@ export default async function AdminUsersPage() {
</td> </td>
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td> <td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="flex items-center gap-3"> <span className="flex items-center gap-3 flex-wrap">
{user.role !== "SUPERUSER" && user.role !== "ADMIN" && (
<GrantSuperUserButton userId={user.id} />
)}
<EditUserButton user={{ <EditUserButton user={{
id: user.id, id: user.id,
employeeId: user.employeeId, employeeId: user.employeeId,

View file

@ -21,6 +21,13 @@ export default async function ApprovalDetailPage({ params }: Props) {
const { id } = await params; const { id } = await params;
// Check if manager has uploaded a signature — required to approve
const currentUser = await db.user.findUnique({
where: { id: session.user.id },
select: { signatureKey: true },
});
const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, accounts, vendors] = await Promise.all([ const [po, vessels, accounts, vendors] = await Promise.all([
db.purchaseOrder.findUnique({ db.purchaseOrder.findUnique({
where: { id }, where: { id },
@ -83,7 +90,25 @@ export default async function ApprovalDetailPage({ params }: Props) {
/> />
<div className="mt-6"> <div className="mt-6">
<ApprovalActions poId={po.id} poStatus={po.status} /> {hasSignature ? (
<ApprovalActions poId={po.id} poStatus={po.status} />
) : (
<div className="rounded-lg border border-warning-200 bg-warning-50 p-5 flex items-start gap-3">
<span className="text-warning-500 text-xl leading-none mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-warning-800">Signature required to approve POs</p>
<p className="text-sm text-warning-700 mt-0.5">
You must upload your approval signature before you can approve, reject, or request edits on purchase orders.
</p>
<a
href="/profile"
className="mt-2 inline-block text-sm font-medium text-primary-600 hover:text-primary-700 underline"
>
Go to Profile to upload your signature
</a>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -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<Result> {
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<Result> {
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<Result> {
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<Result> {
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 };
}

View file

@ -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<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Current password
</label>
<input
type="password"
name="currentPassword"
required
autoComplete="current-password"
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
New password
</label>
<input
type="password"
name="newPassword"
required
minLength={8}
autoComplete="new-password"
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Confirm new password
</label>
<input
type="password"
name="confirmPassword"
required
minLength={8}
autoComplete="new-password"
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
{error && (
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
{success && (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">
Password changed successfully.
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
>
{pending ? "Saving…" : "Change Password"}
</button>
</form>
);
}

View file

@ -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<string, string> = {
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 (
<div className="max-w-2xl space-y-8">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">My Profile</h1>
<p className="mt-1 text-sm text-neutral-500">Manage your account settings</p>
</div>
{/* Account Info */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Account Information</h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<dt className="text-neutral-500">Name</dt>
<dd className="font-medium text-neutral-900">{user.name}</dd>
</div>
<div>
<dt className="text-neutral-500">Email</dt>
<dd className="font-medium text-neutral-900">{user.email}</dd>
</div>
<div>
<dt className="text-neutral-500">Employee ID</dt>
<dd className="font-mono text-sm font-medium text-neutral-900">{user.employeeId}</dd>
</div>
<div>
<dt className="text-neutral-500">Role</dt>
<dd>
<span className="inline-flex items-center rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700">
{ROLE_LABELS[user.role] ?? user.role}
</span>
</dd>
</div>
</dl>
</section>
{/* Change Password */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Change Password</h2>
<ChangePasswordForm />
</section>
{/* Signature (managers & superusers) */}
{canHaveSignature && (
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<div className="mb-4">
<h2 className="text-base font-semibold text-neutral-900">Approval Signature</h2>
<p className="mt-1 text-sm text-neutral-500">
Your signature is embedded in approved PO documents (PDF and XLSX).
{!user.signatureKey && (
<span className="ml-1 font-medium text-warning-700">
A signature is required to approve purchase orders.
</span>
)}
</p>
</div>
<SignatureUploader currentSignatureUrl={signatureUrl} />
</section>
)}
{/* SuperUser access request */}
{canRequestSuperUser && (
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-2">SuperUser Access</h2>
<SuperUserRequestForm pendingRequest={latestRequest} />
</section>
)}
</div>
);
}

View file

@ -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<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [removing, setRemoving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError("");
setSuccess("");
setPreview(URL.createObjectURL(file));
}
async function handleUpload(e: React.FormEvent<HTMLFormElement>) {
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 (
<div className="space-y-4">
{displayUrl && (
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-4 inline-block">
<p className="text-xs font-medium text-neutral-500 mb-2">
{preview ? "Preview" : "Current signature"}
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={displayUrl}
alt="Signature"
className="max-h-24 max-w-xs object-contain border border-neutral-200 rounded bg-white p-2"
/>
</div>
)}
<form onSubmit={handleUpload} className="space-y-3">
<div
className="relative rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-6 text-center cursor-pointer hover:border-primary-400 hover:bg-primary-50 transition-colors"
onClick={() => inputRef.current?.click()}
>
<Upload className="mx-auto h-8 w-8 text-neutral-400 mb-2" />
<p className="text-sm text-neutral-600">
Click to select signature image
</p>
<p className="text-xs text-neutral-400 mt-1">PNG, JPG or WebP max 2 MB</p>
<input
ref={inputRef}
type="file"
name="signature"
accept="image/png,image/jpeg,image/jpg,image/webp"
onChange={handleFileChange}
className="sr-only"
/>
</div>
{error && (
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
{success && (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{success}</p>
)}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={pending || !preview}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{pending ? "Saving…" : "Save Signature"}
</button>
{currentSignatureUrl && !preview && (
<button
type="button"
onClick={handleRemove}
disabled={removing}
className="inline-flex items-center gap-1.5 rounded-lg border border-danger-200 bg-white px-3 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-50 transition-colors"
>
<X className="h-3.5 w-3.5" />
{removing ? "Removing…" : "Remove"}
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -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 (
<div className={`rounded-lg border px-4 py-3 text-sm ${
isPending
? "border-warning-200 bg-warning-50 text-warning-800"
: isApproved
? "border-success-200 bg-success-50 text-success-800"
: "border-danger-200 bg-danger-50 text-danger-800"
}`}>
{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."}
</div>
);
}
if (submitted) {
return (
<div className="rounded-lg border border-success-200 bg-success-50 px-4 py-3 text-sm text-success-800">
Your request has been submitted. An admin will review it shortly.
</div>
);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-neutral-600">
SuperUser access grants additional cross-team permissions. Describe why you need elevated access and an admin will review your request.
</p>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Reason (optional)
</label>
<textarea
name="reason"
rows={3}
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 resize-none"
placeholder="Explain why you need SuperUser access…"
/>
</div>
{error && (
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
>
{pending ? "Submitting…" : "Request SuperUser Access"}
</button>
</form>
);
}

View file

@ -3,6 +3,7 @@ import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po"; import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { downloadBuffer } from "@/lib/storage";
// ── Company constants ───────────────────────────────────────────────────────── // ── Company constants ─────────────────────────────────────────────────────────
@ -69,6 +70,24 @@ export async function GET(request: NextRequest, { params }: Props) {
.find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE"); .find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
const approvedBy = approvalAction?.actor.name ?? ""; const approvedBy = approvalAction?.actor.name ?? "";
// Fetch approver's signature for embedding in the document
let signatureBase64: string | null = null;
let signatureMime = "image/png";
if (approvalAction) {
const approver = await db.user.findUnique({
where: { id: approvalAction.actorId },
select: { signatureKey: true },
});
if (approver?.signatureKey) {
const buf = await downloadBuffer(approver.signatureKey);
if (buf) {
signatureBase64 = buf.toString("base64");
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
signatureMime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
}
}
}
const ext = po as { const ext = po as {
piQuotationNo?: string | null; piQuotationDate?: Date | null; piQuotationNo?: string | null; piQuotationDate?: Date | null;
requisitionNo?: string | null; requisitionDate?: Date | null; requisitionNo?: string | null; requisitionDate?: Date | null;
@ -356,12 +375,30 @@ export async function GET(request: NextRequest, { params }: Props) {
ws.getRow(SIG_ROW + 2).height = 14; ws.getRow(SIG_ROW + 2).height = 14;
// Left sig block (approver — the manager who authorized the PO) // Left sig block (approver — the manager who authorized the PO)
sc(SIG_ROW, 1, approvedBy || po.submitter.name, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC }); if (signatureBase64) {
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
// Span the image across columns A-D in the sig row
ws.addImage(imgId, {
tl: { col: 0, row: SIG_ROW - 1 },
br: { col: 4, row: SIG_ROW },
editAs: "oneCell",
});
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
} else {
sc(SIG_ROW, 1, approvedBy || po.submitter.name, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
}
ws.mergeCells(`A${SIG_ROW}:D${SIG_ROW}`); ws.mergeCells(`A${SIG_ROW}:D${SIG_ROW}`);
sc(SIG_ROW + 1, 1, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC }); sc(SIG_ROW + 1, 1, approvedBy || po.submitter.name, { font: fBold, border: { left: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 1}:D${SIG_ROW + 1}`); ws.mergeCells(`A${SIG_ROW + 1}:D${SIG_ROW + 1}`);
sc(SIG_ROW + 2, 1, "For, Pelagia Marine Services Pvt. Ltd.", { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC }); sc(SIG_ROW + 2, 1, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 2}:D${SIG_ROW + 2}`); ws.mergeCells(`A${SIG_ROW + 2}:D${SIG_ROW + 2}`);
sc(SIG_ROW + 3, 1, "For, Pelagia Marine Services Pvt. Ltd.", { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 3}:D${SIG_ROW + 3}`);
// Adjust row heights when signature present
ws.getRow(SIG_ROW + 1).height = 14;
ws.getRow(SIG_ROW + 2).height = 14;
ws.getRow(SIG_ROW + 3).height = 14;
// Right sig block (vendor) // Right sig block (vendor)
const vName = po.vendor?.name ?? ""; const vName = po.vendor?.name ?? "";
@ -636,8 +673,12 @@ export async function GET(request: NextRequest, { params }: Props) {
<!-- ── Signatures ────────────────────────────────────────────── --> <!-- ── Signatures ────────────────────────────────────────────── -->
<div class="sig"> <div class="sig">
<div class="sig-box"> <div class="sig-box">
<div class="sig-name">${approvedBy || po.submitter.name}</div> ${signatureBase64
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
: `<div class="sig-name">${approvedBy || po.submitter.name}</div>`
}
<div> <div>
<div class="sig-sub" style="font-weight:bold">${approvedBy || po.submitter.name}</div>
<div class="sig-sub">Authorized Signatory &amp; Stamp</div> <div class="sig-sub">Authorized Signatory &amp; Stamp</div>
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div> <div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
</div> </div>

View file

@ -21,6 +21,8 @@ import {
MapPin, MapPin,
ShoppingCart, ShoppingCart,
BarChart3, BarChart3,
UserCircle,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -40,6 +42,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] }, { href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] }, { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
{ href: "/profile", label: "My Profile", icon: UserCircle },
]; ];
const INVENTORY_ITEMS: NavItem[] = [ const INVENTORY_ITEMS: NavItem[] = [
@ -54,6 +57,7 @@ const INVENTORY_ITEMS: NavItem[] = [
const ADMIN_ITEMS: NavItem[] = [ const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/users", label: "Users", icon: Users }, { href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
{ href: "/admin/accounts", label: "Accounts", icon: Building2 }, { href: "/admin/accounts", label: "Accounts", icon: Building2 },
{ href: "/reports", label: "Reports", icon: BarChart3 }, { href: "/reports", label: "Reports", icon: BarChart3 },
]; ];

View file

@ -52,3 +52,63 @@ export function buildStorageKey(
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `${type}/${poId}/${timestamp}-${safe}`; return `${type}/${poId}/${timestamp}-${safe}`;
} }
export function buildSignatureKey(userId: string, ext: string): string {
return `signatures/${userId}.${ext}`;
}
/**
* Upload a file buffer directly to storage (server-side).
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
*/
export async function uploadBuffer(
key: string,
buffer: Buffer,
contentType: string
): Promise<void> {
if (isDev) {
const fs = await import("fs/promises");
const path = await import("path");
const dir = path.join(process.cwd(), ".dev-uploads", ...key.split("/").slice(0, -1));
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, buffer);
} else {
const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
await s3.send(new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: key,
Body: buffer,
ContentType: contentType,
}));
}
}
/**
* Fetch a stored file as a Buffer (server-side).
*/
export async function downloadBuffer(key: string): Promise<Buffer | null> {
try {
if (isDev) {
const fs = await import("fs/promises");
const path = await import("path");
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
return await fs.readFile(filePath) as Buffer;
} else {
const url = await generateDownloadUrl(key, 60);
const res = await fetch(url);
if (!res.ok) return null;
return Buffer.from(await res.arrayBuffer());
}
} catch {
return null;
}
}

View file

@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "RequestStatus" AS ENUM ('PENDING', 'APPROVED', 'DENIED');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "signatureKey" TEXT;
-- CreateTable
CREATE TABLE "SuperUserRequest" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT,
"status" "RequestStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedAt" TIMESTAMP(3),
"resolvedById" TEXT,
CONSTRAINT "SuperUserRequest_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SuperUserRequest" ADD CONSTRAINT "SuperUserRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SuperUserRequest" ADD CONSTRAINT "SuperUserRequest_resolvedById_fkey" FOREIGN KEY ("resolvedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -49,6 +49,12 @@ enum ActionType {
MANAGER_LINE_EDIT MANAGER_LINE_EDIT
} }
enum RequestStatus {
PENDING
APPROVED
DENIED
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
employeeId String @unique employeeId String @unique
@ -57,13 +63,28 @@ model User {
passwordHash String passwordHash String
role Role role Role
isActive Boolean @default(true) isActive Boolean @default(true)
signatureKey String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
submittedPOs PurchaseOrder[] @relation("Submitter") submittedPOs PurchaseOrder[] @relation("Submitter")
actions POAction[] actions POAction[]
notifications Notification[] notifications Notification[]
consumption ItemConsumption[] consumption ItemConsumption[]
superUserRequests SuperUserRequest[] @relation("Requester")
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
}
model SuperUserRequest {
id String @id @default(cuid())
userId String
user User @relation("Requester", fields: [userId], references: [id])
reason String?
status RequestStatus @default(PENDING)
createdAt DateTime @default(now())
resolvedAt DateTime?
resolvedById String?
resolvedBy User? @relation("RequestResolver", fields: [resolvedById], references: [id])
} }
model Site { model Site {