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> {
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" };
}
// 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({
name: formData.get("name"),
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" };
const data = parsed.data;
if (data.vendorId) {
const exists = await db.vendor.findUnique({ where: { vendorId: data.vendorId } });
// Only verifiers may assign a Vendor ID (which marks a vendor as verified).
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" };
}
@ -77,18 +83,33 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
await db.vendor.create({
data: {
name: data.name,
vendorId: data.vendorId ?? null,
vendorId: vendorId ?? null,
address: data.address ?? null,
pincode: data.pincode ?? null,
gstin: data.gstin ?? null,
latitude,
longitude,
isVerified: !!data.vendorId,
isVerified: canVerify ? !!vendorId : false,
contacts: contacts.length > 0 ? { create: contacts } : undefined,
},
});
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 };
}

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 [name, setName] = useState(vendor?.name ?? "");
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>
<input name="name" value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} />
</div>
{!simple && (
<div>
<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>
<input name="vendorId" defaultValue={vendor?.vendorId ?? suggestedVendorId ?? ""} className={INPUT} placeholder="VND-001" />
</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 */}
<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 [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -264,7 +271,7 @@ export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
</button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<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>}
<div className="flex justify-end gap-3">
<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 { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { deleteVendor, toggleVendorActive } from "./actions";
import { deleteVendor, toggleVendorActive, verifyVendor } from "./actions";
type ContactRow = {
name: string;
@ -37,11 +37,15 @@ function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
const [verifyOpen, setVerifyOpen] = useState(false);
return (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
{!vendor.isVerified && (
<RowActionsItem onClick={() => setVerifyOpen(true)}>Verify vendor</RowActionsItem>
)}
<RowActionsItem onClick={() => setToggleOpen(true)}>
{vendor.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
@ -49,6 +53,15 @@ function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</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
vendor={{
id: vendor.id,

View file

@ -2,7 +2,9 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { distanceKm } from "@/lib/geo";
import { hasPermission } from "@/lib/permissions";
import { VendorsTable } from "./vendors-table";
import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendors" };
@ -62,12 +64,19 @@ 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 (
<div className="max-w-6xl">
<div className="mb-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<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>
<VendorsTable
vendors={rows}
hasSite={!!site}

View file

@ -148,6 +148,13 @@ export async function confirmReceipt({
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 } });
if (newStatus === "CLOSED") {
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("/dashboard");
return { id: po.id };

View file

@ -2,6 +2,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { buildSignatureKey, uploadBuffer } from "@/lib/storage";
import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
@ -53,8 +54,8 @@ 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" };
if (!hasPermission(session.user.role, "approve_po")) {
return { error: "Only approvers can upload a signature" };
}
const file = formData.get("signature") as File | null;

View file

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

View file

@ -1,6 +1,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { hasPermission } from "@/lib/permissions";
import { generateDownloadUrl } from "@/lib/storage";
import { ChangePasswordForm } from "./change-password-form";
import { SignatureUploader } from "./signature-uploader";
@ -23,30 +24,44 @@ 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: {
const userSelect = {
id: true,
name: true,
email: true,
employeeId: true,
role: true,
signatureKey: true,
passwordHash: true,
superUserRequests: {
orderBy: { createdAt: "desc" },
orderBy: { createdAt: "desc" as const },
take: 1,
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");
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";
// SSO-only users have no password yet; the form lets them set one.
const hasPassword = !!user.passwordHash;
const signatureUrl = user.signatureKey
? await generateDownloadUrl(user.signatureKey)
: null;
// Never let a storage hiccup (missing key, R2 misconfig) crash the profile page.
let signatureUrl: string | null = null;
if (user.signatureKey) {
try {
signatureUrl = await generateDownloadUrl(user.signatureKey);
} catch {
signatureUrl = null;
}
}
const latestRequest = user.superUserRequests[0] ?? null;
@ -84,10 +99,17 @@ export default async function ProfilePage() {
</dl>
</section>
{/* Change Password */}
{/* Change / Set 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 />
<h2 className="text-base font-semibold text-neutral-900 mb-1">
{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>
{/* Signature (managers & superusers) */}

View file

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