diff --git a/App/.env.example b/App/.env.example index bc10979..4d7757b 100644 --- a/App/.env.example +++ b/App/.env.example @@ -15,6 +15,14 @@ NEXTAUTH_SECRET=your-32-char-secret-here-generate-with-openssl NEXTAUTH_URL=http://localhost:3000 +# ── Microsoft Entra ID (Azure AD) SSO ──────────────────────── +# Register an app at https://entra.microsoft.com +# Required redirect URI: {NEXTAUTH_URL}/api/auth/callback/microsoft-entra-id +# Grant: openid, profile, email (Microsoft Graph delegated permissions) +AZURE_AD_CLIENT_ID=your-azure-app-client-id +AZURE_AD_CLIENT_SECRET=your-azure-app-client-secret +AZURE_AD_TENANT_ID=your-azure-tenant-id + # ── Database ────────────────────────────────────────────────── # Local PostgreSQL or Supabase DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal" diff --git a/App/app/(auth)/login/login-form.tsx b/App/app/(auth)/login/login-form.tsx index cfea2b5..cb10352 100644 --- a/App/app/(auth)/login/login-form.tsx +++ b/App/app/(auth)/login/login-form.tsx @@ -9,10 +9,16 @@ export function LoginForm() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [ssoLoading, setSsoLoading] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + async function handleSso() { + setSsoLoading(true); + await signIn("microsoft-entra-id", { callbackUrl }); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); @@ -34,58 +40,90 @@ export function LoginForm() { } return ( -
-
- - setEmail(e.target.value)} - className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" - placeholder="you@company.com" - /> -
- -
- - setPassword(e.target.value)} - className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" - placeholder="••••••••" - /> -
- - {error && ( -

- {error} -

- )} - +
- + +
+
+
+
+
+ or sign in with password +
+
+ +
+
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" + placeholder="you@pelagiamarine.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" + placeholder="••••••••" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+ ); +} + +function MicrosoftLogo() { + return ( + + + + + + ); } diff --git a/App/app/(portal)/admin/users/actions.ts b/App/app/(portal)/admin/users/actions.ts index 97e4dd4..19e6d8d 100644 --- a/App/app/(portal)/admin/users/actions.ts +++ b/App/app/(portal)/admin/users/actions.ts @@ -33,7 +33,6 @@ export async function createUser(formData: FormData): Promise { if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const data = parsed.data; - if (!data.password) return { error: "Password is required for new users" }; const exists = await db.user.findFirst({ where: { email: data.email }, @@ -48,7 +47,7 @@ export async function createUser(formData: FormData): Promise { }); const employeeId = nextId(prefix, existingIds.map((u) => u.employeeId)); - const passwordHash = await bcrypt.hash(data.password, 12); + const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : null; await db.user.create({ data: { employeeId, diff --git a/App/app/(portal)/admin/users/user-form.tsx b/App/app/(portal)/admin/users/user-form.tsx index 2c747f6..038bbe3 100644 --- a/App/app/(portal)/admin/users/user-form.tsx +++ b/App/app/(portal)/admin/users/user-form.tsx @@ -62,10 +62,9 @@ function UserFormFields({ user }: { user?: UserRow }) {
diff --git a/App/app/(portal)/profile/actions.ts b/App/app/(portal)/profile/actions.ts index c34c0f3..46ca699 100644 --- a/App/app/(portal)/profile/actions.ts +++ b/App/app/(portal)/profile/actions.ts @@ -31,6 +31,7 @@ export async function changePassword(formData: FormData): Promise { select: { passwordHash: true }, }); if (!user) return { error: "User not found" }; + if (!user.passwordHash) return { error: "Password change is not available for accounts that sign in via Microsoft 365." }; const valid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); if (!valid) return { error: "Current password is incorrect" }; diff --git a/App/auth.ts b/App/auth.ts index 5c77f56..91dbb68 100644 --- a/App/auth.ts +++ b/App/auth.ts @@ -1,5 +1,6 @@ import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; +import MicrosoftEntra from "next-auth/providers/microsoft-entra-id"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { loginSchema } from "@/lib/validations/user"; @@ -13,6 +14,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ error: "/login", }, providers: [ + MicrosoftEntra({ + clientId: process.env.AZURE_AD_CLIENT_ID!, + clientSecret: process.env.AZURE_AD_CLIENT_SECRET!, + // Restricts sign-in to Pelagia Marine's M365 tenant. Without this, + // any Microsoft account (personal or other org) could authenticate. + issuer: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}/v2.0`, + }), Credentials({ credentials: { email: { label: "Email", type: "email" }, @@ -25,7 +33,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const user = await db.user.findUnique({ where: { email: parsed.data.email }, }); - if (!user || !user.isActive) return null; + if (!user || !user.isActive || !user.passwordHash) return null; const valid = await bcrypt.compare(parsed.data.password, user.passwordHash); if (!valid) return null; @@ -35,13 +43,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }), ], callbacks: { - jwt({ token, user }) { - if (user) { + async signIn({ account, profile }) { + if (account?.provider !== "microsoft-entra-id") return true; + + const email = profile?.email ?? (profile as Record)?.preferred_username as string; + if (!email) return false; + + const dbUser = await db.user.findUnique({ where: { email } }); + return !!(dbUser?.isActive); + }, + + async jwt({ token, user, account }) { + if (account?.provider === "credentials" && user) { token.id = user.id; token.role = (user as unknown as { role: Role }).role; } + if (account?.provider === "microsoft-entra-id") { + const email = token.email; + if (email) { + const dbUser = await db.user.findUnique({ where: { email } }); + if (dbUser) { + token.id = dbUser.id; + token.role = dbUser.role; + } + } + } return token; }, + session({ session, token }) { session.user.id = token.id as string; session.user.role = token.role as Role; diff --git a/App/prisma/migrations/20260528000000_optional_password_hash_for_sso/migration.sql b/App/prisma/migrations/20260528000000_optional_password_hash_for_sso/migration.sql new file mode 100644 index 0000000..8b9583b --- /dev/null +++ b/App/prisma/migrations/20260528000000_optional_password_hash_for_sso/migration.sql @@ -0,0 +1,3 @@ +-- Make passwordHash nullable to support SSO users who authenticate via Microsoft Entra +-- and do not have a local password. +ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 17a0cd1..bba5f41 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -62,7 +62,7 @@ model User { employeeId String @unique email String @unique name String - passwordHash String + passwordHash String? role Role isActive Boolean @default(true) signatureKey String?