fix(profile+vendors): profile reachable for all roles incl SSO; submitter vendor creation

Profile (fixes Safari/SSO no-password redirect):
- User lookup falls back to email when JWT id is stale (SSO users)
- generateDownloadUrl wrapped in try/catch so storage never crashes the page
- Signature gate now uses approve_po permission (approvers only)
- SSO/no-password users see a Set Password form (current-password field hidden)

Vendors:
- New create_vendor permission for all PO roles incl. submitters
- Submitters create UNVERIFIED vendors (no Vendor ID); simple form mode
- verifyVendor action + Verify menu item (manage_vendors)
- Vendors auto-verify when a PO closes with them (receipt confirm + import)
- Add Vendor button on /inventory/vendors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-08 18:53:33 +05:30
parent b5a5097ab5
commit eb402e03ef
10 changed files with 149 additions and 56 deletions

View file

@ -52,10 +52,14 @@ async function resolveLatLng(pincode?: string) {
export async function createVendor(formData: FormData): Promise<ActionResult> { export async function createVendor(formData: FormData): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) { if (!session?.user || !hasPermission(session.user.role, "create_vendor")) {
return { error: "Unauthorized" }; return { error: "Unauthorized" };
} }
// Submitters (no manage_vendors) may create vendors, but they stay UNVERIFIED
// until a PO closes with them or a Manager/Accounts/Admin approves them.
const canVerify = hasPermission(session.user.role, "manage_vendors");
const parsed = vendorSchema.safeParse({ const parsed = vendorSchema.safeParse({
name: formData.get("name"), name: formData.get("name"),
vendorId: formData.get("vendorId") || undefined, vendorId: formData.get("vendorId") || undefined,
@ -66,8 +70,10 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data; const data = parsed.data;
if (data.vendorId) { // Only verifiers may assign a Vendor ID (which marks a vendor as verified).
const exists = await db.vendor.findUnique({ where: { vendorId: data.vendorId } }); const vendorId = canVerify ? data.vendorId : undefined;
if (vendorId) {
const exists = await db.vendor.findUnique({ where: { vendorId } });
if (exists) return { error: "A vendor with that Vendor ID already exists" }; if (exists) return { error: "A vendor with that Vendor ID already exists" };
} }
@ -77,18 +83,33 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
await db.vendor.create({ await db.vendor.create({
data: { data: {
name: data.name, name: data.name,
vendorId: data.vendorId ?? null, vendorId: vendorId ?? null,
address: data.address ?? null, address: data.address ?? null,
pincode: data.pincode ?? null, pincode: data.pincode ?? null,
gstin: data.gstin ?? null, gstin: data.gstin ?? null,
latitude, latitude,
longitude, longitude,
isVerified: !!data.vendorId, isVerified: canVerify ? !!vendorId : false,
contacts: contacts.length > 0 ? { create: contacts } : undefined, contacts: contacts.length > 0 ? { create: contacts } : undefined,
}, },
}); });
revalidatePath("/admin/vendors"); revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
return { ok: true };
}
/** Approve / verify a vendor — Manager, Accounts or Admin only. */
export async function verifyVendor(vendorId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
revalidatePath(`/admin/vendors/${vendorId}`);
return { ok: true }; return { ok: true };
} }

View file

@ -113,7 +113,7 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
); );
} }
function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; suggestedVendorId?: string }) { function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? ""); const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? ""); const [name, setName] = useState(vendor?.name ?? "");
const [address, setAddress] = useState(vendor?.address ?? ""); const [address, setAddress] = useState(vendor?.address ?? "");
@ -217,11 +217,18 @@ function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; s
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label>
<input name="name" value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} /> <input name="name" value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} />
</div> </div>
<div> {!simple && (
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID <span className="text-neutral-400 font-normal">(leave blank if unverified)</span></label> <div>
<input name="vendorId" defaultValue={vendor?.vendorId ?? suggestedVendorId ?? ""} className={INPUT} placeholder="VND-001" /> <label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID <span className="text-neutral-400 font-normal">(leave blank if unverified)</span></label>
</div> <input name="vendorId" defaultValue={vendor?.vendorId ?? suggestedVendorId ?? ""} className={INPUT} placeholder="VND-001" />
</div>
)}
</div> </div>
{simple && (
<p className="rounded-lg bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">
This vendor will be created as <strong>unverified</strong>. It becomes verified once a PO is closed with it, or after a Manager/Accounts review.
</p>
)}
{/* Address + Pincode */} {/* Address + Pincode */}
<div> <div>
@ -243,7 +250,7 @@ function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; s
); );
} }
export function AddVendorButton({ suggestedId }: { suggestedId?: string }) { export function AddVendorButton({ suggestedId, simple = false }: { suggestedId?: string; simple?: boolean }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -264,7 +271,7 @@ export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
</button> </button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}> <AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields suggestedVendorId={suggestedId} /> <VendorFormFields suggestedVendorId={suggestedId} simple={simple} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} <button type="button" onClick={() => setOpen(false)}

View file

@ -8,7 +8,7 @@ import { AddVendorButton, EditVendorButton } from "./vendor-form";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { deleteVendor, toggleVendorActive } from "./actions"; import { deleteVendor, toggleVendorActive, verifyVendor } from "./actions";
type ContactRow = { type ContactRow = {
name: string; name: string;
@ -37,11 +37,15 @@ function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false);
const [verifyOpen, setVerifyOpen] = useState(false);
return ( return (
<> <>
<RowActionsMenu> <RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem> <RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
{!vendor.isVerified && (
<RowActionsItem onClick={() => setVerifyOpen(true)}>Verify vendor</RowActionsItem>
)}
<RowActionsItem onClick={() => setToggleOpen(true)}> <RowActionsItem onClick={() => setToggleOpen(true)}>
{vendor.isActive ? "Deactivate" : "Activate"} {vendor.isActive ? "Deactivate" : "Activate"}
</RowActionsItem> </RowActionsItem>
@ -49,6 +53,15 @@ function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem> <RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu> </RowActionsMenu>
<ConfirmDialog
open={verifyOpen}
onOpenChange={setVerifyOpen}
title={`Verify ${vendor.name}?`}
description="Marks this vendor as verified and approved for use across the portal."
confirmLabel="Verify"
onConfirm={() => verifyVendor(vendor.id)}
/>
<EditVendorButton <EditVendorButton
vendor={{ vendor={{
id: vendor.id, id: vendor.id,

View file

@ -2,7 +2,9 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { distanceKm } from "@/lib/geo"; import { distanceKm } from "@/lib/geo";
import { hasPermission } from "@/lib/permissions";
import { VendorsTable } from "./vendors-table"; import { VendorsTable } from "./vendors-table";
import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendors" }; export const metadata: Metadata = { title: "Vendors" };
@ -62,11 +64,18 @@ export default async function InventoryVendorsPage({ searchParams }: Props) {
}; };
}); });
// Submitters can add vendors here; without manage_vendors the vendor stays unverified.
const canCreate = hasPermission(session.user.role, "create_vendor");
const canVerify = hasPermission(session.user.role, "manage_vendors");
return ( return (
<div className="max-w-6xl"> <div className="max-w-6xl">
<div className="mb-6"> <div className="mb-6 flex items-start justify-between gap-4">
<h1 className="text-2xl font-semibold text-neutral-900">Vendors</h1> <div>
<p className="mt-1 text-sm text-neutral-500">Browse vendors and their distance from your working site.</p> <h1 className="text-2xl font-semibold text-neutral-900">Vendors</h1>
<p className="mt-1 text-sm text-neutral-500">Browse vendors and their distance from your working site.</p>
</div>
{canCreate && <AddVendorButton simple={!canVerify} />}
</div> </div>
<VendorsTable <VendorsTable
vendors={rows} vendors={rows}

View file

@ -148,6 +148,13 @@ export async function confirmReceipt({
revalidatePath(`/admin/sites/${siteId}`); revalidatePath(`/admin/sites/${siteId}`);
} }
// Closing a PO auto-verifies its vendor (proof of a real, completed transaction).
if (newStatus === "CLOSED" && po.vendorId) {
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
}
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } }); const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
if (newStatus === "CLOSED") { if (newStatus === "CLOSED") {
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }); const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });

View file

@ -185,6 +185,13 @@ export async function importPo(
}, },
}); });
// Imported PO is CLOSED → its vendor is proven by a real transaction, so verify it.
if (resolvedVendorId) {
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
}
revalidatePath("/history"); revalidatePath("/history");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { id: po.id }; return { id: po.id };

View file

@ -2,6 +2,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { buildSignatureKey, uploadBuffer } from "@/lib/storage"; import { buildSignatureKey, uploadBuffer } from "@/lib/storage";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@ -53,8 +54,8 @@ export async function saveSignature(formData: FormData): Promise<Result> {
const session = await auth(); const session = await auth();
if (!session?.user) return { error: "Unauthorized" }; if (!session?.user) return { error: "Unauthorized" };
if (session.user.role !== "MANAGER" && session.user.role !== "SUPERUSER") { if (!hasPermission(session.user.role, "approve_po")) {
return { error: "Only managers and superusers can upload a signature" }; return { error: "Only approvers can upload a signature" };
} }
const file = formData.get("signature") as File | null; const file = formData.get("signature") as File | null;

View file

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { changePassword } from "./actions"; import { changePassword } from "./actions";
export function ChangePasswordForm() { export function ChangePasswordForm({ hasPassword = true }: { hasPassword?: boolean }) {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -36,17 +36,19 @@ export function ChangePasswordForm() {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> {hasPassword && (
<label className="block text-sm font-medium text-neutral-700 mb-1.5"> <div>
Current password <label className="block text-sm font-medium text-neutral-700 mb-1.5">
</label> Current password
<input </label>
type="password" <input
name="currentPassword" type="password"
autoComplete="current-password" name="currentPassword"
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" 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>
)}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5"> <label className="block text-sm font-medium text-neutral-700 mb-1.5">
New password New password
@ -88,7 +90,7 @@ export function ChangePasswordForm() {
disabled={pending} 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" 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"} {pending ? "Saving…" : hasPassword ? "Change Password" : "Set Password"}
</button> </button>
</form> </form>
); );

View file

@ -1,6 +1,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { hasPermission } from "@/lib/permissions";
import { generateDownloadUrl } from "@/lib/storage"; import { generateDownloadUrl } from "@/lib/storage";
import { ChangePasswordForm } from "./change-password-form"; import { ChangePasswordForm } from "./change-password-form";
import { SignatureUploader } from "./signature-uploader"; import { SignatureUploader } from "./signature-uploader";
@ -23,30 +24,44 @@ export default async function ProfilePage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
const user = await db.user.findUnique({ const userSelect = {
where: { id: session.user.id }, id: true,
select: { name: true,
id: true, email: true,
name: true, employeeId: true,
email: true, role: true,
employeeId: true, signatureKey: true,
role: true, passwordHash: true,
signatureKey: true, superUserRequests: {
superUserRequests: { orderBy: { createdAt: "desc" as const },
orderBy: { createdAt: "desc" }, take: 1,
take: 1, select: { status: true, createdAt: true },
select: { status: true, createdAt: true },
},
}, },
}); };
// Look up by id, falling back to email. SSO/no-password users can carry a JWT
// whose `id` differs from the DB row; the email fallback keeps the page reachable.
let user = await db.user.findUnique({ where: { id: session.user.id }, select: userSelect });
if (!user && session.user.email) {
user = await db.user.findUnique({ where: { email: session.user.email }, select: userSelect });
}
if (!user) redirect("/login"); if (!user) redirect("/login");
const canHaveSignature = user.role === "MANAGER" || user.role === "SUPERUSER"; // Only approvers (those who can approve POs) may upload a signature.
const canHaveSignature = hasPermission(user.role, "approve_po");
const canRequestSuperUser = user.role !== "SUPERUSER" && user.role !== "ADMIN"; const canRequestSuperUser = user.role !== "SUPERUSER" && user.role !== "ADMIN";
// SSO-only users have no password yet; the form lets them set one.
const hasPassword = !!user.passwordHash;
const signatureUrl = user.signatureKey // Never let a storage hiccup (missing key, R2 misconfig) crash the profile page.
? await generateDownloadUrl(user.signatureKey) let signatureUrl: string | null = null;
: null; if (user.signatureKey) {
try {
signatureUrl = await generateDownloadUrl(user.signatureKey);
} catch {
signatureUrl = null;
}
}
const latestRequest = user.superUserRequests[0] ?? null; const latestRequest = user.superUserRequests[0] ?? null;
@ -84,10 +99,17 @@ export default async function ProfilePage() {
</dl> </dl>
</section> </section>
{/* Change Password */} {/* Change / Set Password */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <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> <h2 className="text-base font-semibold text-neutral-900 mb-1">
<ChangePasswordForm /> {hasPassword ? "Change Password" : "Set Password"}
</h2>
{!hasPassword && (
<p className="mb-4 text-sm text-neutral-500">
You sign in with single sign-on. Optionally set a password to also sign in with email.
</p>
)}
<ChangePasswordForm hasPassword={hasPassword} />
</section> </section>
{/* Signature (managers & superusers) */} {/* Signature (managers & superusers) */}

View file

@ -16,14 +16,15 @@ export type Permission =
| "export_reports" | "export_reports"
| "manage_users" | "manage_users"
| "manage_vendors" | "manage_vendors"
| "create_vendor"
| "manage_vessels_accounts" | "manage_vessels_accounts"
| "manage_products" | "manage_products"
| "manage_sites"; | "manage_sites";
const ROLE_PERMISSIONS: Record<Role, Permission[]> = { const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"], MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors"], ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"],
MANAGER: [ MANAGER: [
"create_po", "create_po",
"submit_po", "submit_po",
@ -37,6 +38,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_analytics", "view_analytics",
"export_reports", "export_reports",
"manage_vendors", "manage_vendors",
"create_vendor",
"manage_vessels_accounts", "manage_vessels_accounts",
"manage_products", "manage_products",
"manage_sites", "manage_sites",
@ -55,6 +57,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"confirm_receipt", "confirm_receipt",
"view_analytics", "view_analytics",
"export_reports", "export_reports",
"create_vendor",
], ],
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"], AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
ADMIN: [ ADMIN: [
@ -64,6 +67,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"export_reports", "export_reports",
"manage_users", "manage_users",
"manage_vendors", "manage_vendors",
"create_vendor",
"manage_vessels_accounts", "manage_vessels_accounts",
"manage_products", "manage_products",
"manage_sites", "manage_sites",