pelagia-portal/App/app/(portal)/crewing/crew/[id]/page.tsx
Hardik 06ff587024 fix(crewing): mask Aadhaar/PAN document numbers server-side
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>
2026-06-22 23:29:11 +05:30

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) }}
/>
);
}