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:
parent
3b3a26eafe
commit
3556b1425f
16 changed files with 1022 additions and 10 deletions
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
“{req.reason}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{hasSignature ? (
|
||||||
<ApprovalActions poId={po.id} poStatus={po.status} />
|
<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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
125
App/pelagia-portal/app/(portal)/profile/actions.ts
Normal file
125
App/pelagia-portal/app/(portal)/profile/actions.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
App/pelagia-portal/app/(portal)/profile/page.tsx
Normal file
120
App/pelagia-portal/app/(portal)/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx
Normal file
128
App/pelagia-portal/app/(portal)/profile/signature-uploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
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 });
|
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 & Stamp</div>
|
<div class="sig-sub">Authorized Signatory & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,6 +63,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -64,6 +71,20 @@ model User {
|
||||||
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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue