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:
parent
b5a5097ab5
commit
eb402e03ef
10 changed files with 149 additions and 56 deletions
31
App/app/(portal)/admin/vendors/actions.ts
vendored
31
App/app/(portal)/admin/vendors/actions.ts
vendored
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
13
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
13
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
|
|
@ -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)}
|
||||
|
|
|
|||
15
App/app/(portal)/admin/vendors/vendors-table.tsx
vendored
15
App/app/(portal)/admin/vendors/vendors-table.tsx
vendored
|
|
@ -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,
|
||||
|
|
|
|||
11
App/app/(portal)/inventory/vendors/page.tsx
vendored
11
App/app/(portal)/inventory/vendors/page.tsx
vendored
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue