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 { AddUserButton, EditUserButton } from "./user-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { GrantSuperUserButton } from "./grant-superuser-button";
|
||||
import { deleteUser } from "./actions";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -68,7 +69,10 @@ export default async function AdminUsersPage() {
|
|||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<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={{
|
||||
id: user.id,
|
||||
employeeId: user.employeeId,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
|
||||
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([
|
||||
db.purchaseOrder.findUnique({
|
||||
where: { id },
|
||||
|
|
@ -83,7 +90,25 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
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 ExcelJS from "exceljs";
|
||||
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||
import { downloadBuffer } from "@/lib/storage";
|
||||
|
||||
// ── Company constants ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -69,6 +70,24 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
.find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
|
||||
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 {
|
||||
piQuotationNo?: string | null; piQuotationDate?: 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;
|
||||
|
||||
// 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}`);
|
||||
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}`);
|
||||
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}`);
|
||||
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)
|
||||
const vName = po.vendor?.name ?? "";
|
||||
|
|
@ -636,8 +673,12 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||
<div class="sig">
|
||||
<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 class="sig-sub" style="font-weight:bold">${approvedBy || po.submitter.name}</div>
|
||||
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
MapPin,
|
||||
ShoppingCart,
|
||||
BarChart3,
|
||||
UserCircle,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
|
|
@ -40,6 +42,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
||||
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
|
||||
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
|
||||
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
||||
];
|
||||
|
||||
const INVENTORY_ITEMS: NavItem[] = [
|
||||
|
|
@ -54,6 +57,7 @@ const INVENTORY_ITEMS: NavItem[] = [
|
|||
|
||||
const ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
||||
{ href: "/admin/accounts", label: "Accounts", icon: Building2 },
|
||||
{ href: "/reports", label: "Reports", icon: BarChart3 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,3 +52,63 @@ export function buildStorageKey(
|
|||
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
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
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
DENIED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
employeeId String @unique
|
||||
|
|
@ -57,13 +63,28 @@ model User {
|
|||
passwordHash String
|
||||
role Role
|
||||
isActive Boolean @default(true)
|
||||
signatureKey String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||||
actions POAction[]
|
||||
notifications Notification[]
|
||||
consumption ItemConsumption[]
|
||||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||||
actions POAction[]
|
||||
notifications Notification[]
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue