The crew profile page passed SeafarerDocument.number to the client unmasked for all roles and all doc types, exposing full Aadhaar/PAN identity numbers to MPO / Manager / Site staff — contradicting the field's PII annotation and §6 / Roles-and-Permissions §3 (Aadhaar/PAN are gated to Accounts/SuperUser, same as the bank account number). - crew-pii.ts: add documentNumberValue(number, docType, role) — masks AADHAAR / PAN for non-privileged roles via the existing canViewFullBankEpf gate + maskTail; non-identity docs (passport, CDC, STCW…) pass through; preserves the string|null contract. - crew/[id]/page.tsx: mask the number server-side before it crosses to the client. - Tests: unit cases for the helper; an integration test that invokes the server component and asserts the documents prop is masked for MANAGER/SITE_STAFF/MPO and full for ACCOUNTS/SUPERUSER. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.8 KiB
TypeScript
113 lines
4.8 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
|
import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
|
import { redirect, notFound } from "next/navigation";
|
|
import { CrewProfile } from "./crew-profile";
|
|
import type { Metadata } from "next";
|
|
|
|
export const metadata: Metadata = { title: "Crew profile" };
|
|
|
|
export default async function CrewProfilePage({ params }: { params: Promise<{ id: string }> }) {
|
|
if (!CREWING_ENABLED) notFound();
|
|
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
const role = session.user.role;
|
|
if (!hasPermission(role, "view_crew_records")) redirect("/dashboard");
|
|
|
|
const { id } = await params;
|
|
const c = await db.crewMember.findUnique({
|
|
where: { id },
|
|
include: {
|
|
currentRank: { select: { name: true } },
|
|
documents: { orderBy: { createdAt: "desc" } },
|
|
bankDetail: true,
|
|
epfDetail: true,
|
|
nextOfKin: { orderBy: { createdAt: "asc" } },
|
|
ppeIssues: { orderBy: { issuedDate: "desc" } },
|
|
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
|
|
assignments: {
|
|
where: { status: { not: "SIGNED_OFF" } },
|
|
orderBy: { signOnDate: "desc" },
|
|
take: 1,
|
|
include: {
|
|
vessel: { select: { name: true } },
|
|
site: { select: { name: true } },
|
|
salaryStructures: { orderBy: { effectiveFrom: "desc" } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!c) notFound();
|
|
if (c.status !== "EMPLOYEE") notFound(); // the Candidates page handles non-crew
|
|
|
|
const assignment = c.assignments[0] ?? null;
|
|
const showSalary = canViewSalary(role);
|
|
const currentSalary = assignment?.salaryStructures.find((s) => s.approvedById) ?? assignment?.salaryStructures[0] ?? null;
|
|
|
|
const ranks = await db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } });
|
|
|
|
const appraisals = await db.appraisal.findMany({
|
|
where: { assignment: { crewMemberId: c.id } },
|
|
orderBy: { createdAt: "desc" },
|
|
select: { id: true, period: true, status: true, comments: true, ratings: true },
|
|
});
|
|
|
|
return (
|
|
<CrewProfile
|
|
crew={{
|
|
id: c.id,
|
|
name: c.name,
|
|
employeeId: c.employeeId ?? "—",
|
|
rank: c.currentRank?.name ?? "—",
|
|
location: assignment?.vessel?.name ?? assignment?.site?.name ?? "—",
|
|
status: assignment?.status ?? null,
|
|
}}
|
|
documents={c.documents.map((d) => ({
|
|
id: d.id,
|
|
docType: d.docType,
|
|
number: documentNumberValue(d.number, d.docType, role),
|
|
issueDate: d.issueDate?.toISOString() ?? null,
|
|
expiryDate: d.expiryDate?.toISOString() ?? null,
|
|
verificationStatus: d.verificationStatus,
|
|
hasFile: Boolean(d.fileKey),
|
|
}))}
|
|
bank={{
|
|
accountName: c.bankDetail?.accountName ?? null,
|
|
accountNumber: bankEpfValue(c.bankDetail?.accountNumber, role),
|
|
ifsc: c.bankDetail?.ifsc ?? null,
|
|
bankName: c.bankDetail?.bankName ?? null,
|
|
}}
|
|
epf={{
|
|
uan: c.epfDetail?.uan ?? null,
|
|
aadhaar: bankEpfValue(c.epfDetail?.aadhaarLast4, role),
|
|
pfNumber: c.epfDetail?.pfNumber ?? null,
|
|
}}
|
|
nextOfKin={c.nextOfKin.map((n) => ({ id: n.id, name: n.name, relationship: n.relationship, phone: n.phone, address: n.address, isEmergency: n.isEmergency }))}
|
|
ppe={c.ppeIssues.map((p) => ({ id: p.id, item: p.item, size: p.size, quantity: p.quantity, issuedDate: p.issuedDate.toISOString(), returnedDate: p.returnedDate?.toISOString() ?? null }))}
|
|
experience={c.experienceRecords.map((e) => ({ id: e.id, vesselType: e.vesselType, rank: e.rank?.name ?? null, fromDate: e.fromDate?.toISOString() ?? null, toDate: e.toDate?.toISOString() ?? null, durationMonths: e.durationMonths, source: e.source }))}
|
|
paystatus={{
|
|
showSalary,
|
|
salary: showSalary && currentSalary
|
|
? { basic: Number(currentSalary.basic), rateBasis: currentSalary.rateBasis, victualingPerDay: Number(currentSalary.victualingPerDay), currency: currentSalary.currency }
|
|
: null,
|
|
}}
|
|
ranks={ranks}
|
|
perms={{
|
|
editRecords: hasPermission(role, "upload_crew_records"),
|
|
issuePpe: hasPermission(role, "issue_ppe"),
|
|
}}
|
|
signOff={{ assignmentId: assignment?.id ?? null, canSignOff: hasPermission(role, "sign_off_crew") && Boolean(assignment) }}
|
|
appraisals={appraisals.map((a) => ({
|
|
id: a.id,
|
|
period: a.period,
|
|
status: a.status,
|
|
comments: a.comments,
|
|
ratings: (a.ratings ?? null) as { competence: number | null; conduct: number | null; safety: number | null } | null,
|
|
}))}
|
|
appraisalCtx={{ assignmentId: assignment?.id ?? null, canRaise: hasPermission(role, "raise_appraisal") && Boolean(assignment) }}
|
|
/>
|
|
);
|
|
}
|