pelagia-portal/App/app/(portal)/admin/users/actions.ts
Hardik 56817a7d86 feat(auth): add Microsoft 365 SSO via Azure Entra ID
Adds the Microsoft Entra ID provider to NextAuth alongside the existing
credentials provider. Sign-in is restricted to Pelagia Marine's M365
tenant via the issuer URL; access is further gated by requiring a
matching active user record in the DB (DB-managed roles remain unchanged).

- auth.ts: add MicrosoftEntra provider, signIn callback (DB lookup),
  async jwt callback to populate id/role on first SSO sign-in
- login-form.tsx: add primary "Sign in with Microsoft 365" button with
  Microsoft logo; credentials form kept as a fallback below a divider
- prisma: make passwordHash nullable (migration applied) to allow
  SSO-only users without a local password
- admin/users: password is now optional when creating a user — leave
  blank for SSO-only accounts
- profile/actions: return a clear error if an SSO user (no passwordHash)
  attempts to use the change-password form
- .env.example: document AZURE_AD_CLIENT_ID/SECRET/TENANT_ID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:48:37 +05:30

137 lines
4.5 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import type { Role } from "@prisma/client";
import { ROLE_PREFIX, nextId } from "@/lib/id-generators";
type ActionResult = { ok: true } | { error: string };
const userSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
role: z.enum(["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"]),
password: z.string().min(8, "Password must be at least 8 characters").optional(),
});
export async function createUser(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) {
return { error: "Unauthorized" };
}
const parsed = userSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
password: formData.get("password") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
const exists = await db.user.findFirst({
where: { email: data.email },
});
if (exists) return { error: "A user with that email already exists" };
// Auto-generate employeeId based on role prefix
const prefix = ROLE_PREFIX[data.role];
const existingIds = await db.user.findMany({
where: { role: data.role as Role },
select: { employeeId: true },
});
const employeeId = nextId(prefix, existingIds.map((u) => u.employeeId));
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : null;
await db.user.create({
data: {
employeeId,
name: data.name,
email: data.email,
role: data.role as Role,
passwordHash,
},
});
revalidatePath("/admin/users");
return { ok: true };
}
export async function updateUser(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) {
return { error: "Unauthorized" };
}
const id = formData.get("id") as string;
if (!id) return { error: "User ID is required" };
const parsed = userSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
password: formData.get("password") || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
const conflict = await db.user.findFirst({
where: {
AND: [
{ id: { not: id } },
{ email: data.email },
],
},
});
if (conflict) return { error: "Another user already has that email" };
const updateData: Parameters<typeof db.user.update>[0]["data"] = {
name: data.name,
email: data.email,
role: data.role as Role,
};
if (data.password) {
updateData.passwordHash = await bcrypt.hash(data.password, 12);
}
await db.user.update({ where: { id }, data: updateData });
revalidatePath("/admin/users");
return { ok: true };
}
export async function deleteUser(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) return { error: "Unauthorized" };
if (id === session.user.id) return { error: "Cannot delete your own account." };
const inUse = await db.purchaseOrder.findFirst({ where: { submitterId: id } });
if (inUse) return { error: "Cannot delete: user has submitted purchase orders. Deactivate them instead." };
await db.$transaction(async (tx) => {
await tx.notification.deleteMany({ where: { userId: id } });
await tx.user.delete({ where: { id } });
});
revalidatePath("/admin/users");
return { ok: true };
}
export async function toggleUserActive(userId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) {
return { error: "Unauthorized" };
}
if (userId === session.user.id) return { error: "You cannot deactivate your own account" };
const user = await db.user.findUnique({ where: { id: userId }, select: { isActive: true } });
if (!user) return { error: "User not found" };
await db.user.update({ where: { id: userId }, data: { isActive: !user.isActive } });
revalidatePath("/admin/users");
return { ok: true };
}