diff --git a/src/app/(auth)/change-password/page.tsx b/src/app/(auth)/change-password/page.tsx new file mode 100644 index 0000000..9f2cbd6 --- /dev/null +++ b/src/app/(auth)/change-password/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export default function ChangePasswordPage() { + const router = useRouter(); + const search = useSearchParams(); + const isFirstLogin = search.get("first") === "1"; + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (newPassword !== confirmPassword) { + setError("New passwords don't match."); + return; + } + if (newPassword.length < 10) { + setError("Password must be at least 10 characters."); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`${BASE_PATH}/api/auth/change-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error ?? "Change failed"); + setSubmitting(false); + return; + } + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Network error — please try again."); + setSubmitting(false); + } + } + + return ( +
+
+

+ {isFirstLogin ? "Set a new password" : "Change password"} +

+

+ {isFirstLogin + ? "This is your first sign-in. Please choose a new password before continuing." + : "Use a password you don't use anywhere else."} +

+ +
+
+ + setCurrentPassword(e.target.value)} + className="h-9 text-sm" + /> +
+
+ + setNewPassword(e.target.value)} + className="h-9 text-sm" + /> +

+ Minimum 10 characters. +

+
+
+ + setConfirmPassword(e.target.value)} + className="h-9 text-sm" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..7e59a5f --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [status, setStatus] = useState<"idle" | "sent">("idle"); + const [devResetUrl, setDevResetUrl] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + + try { + const res = await fetch(`${BASE_PATH}/api/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const data = await res.json().catch(() => ({})); + setStatus("sent"); + if (data?.devResetUrl) { + setDevResetUrl(data.devResetUrl); + } + } catch { + // Fall through to success state — this endpoint never reveals failure. + setStatus("sent"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

+ Forgot your password? +

+ + {status === "idle" ? ( + <> +

+ Enter your email and we'll send a reset link. +

+
+
+ + setEmail(e.target.value)} + className="h-9 text-sm" + /> +
+ +
+ + ) : ( +
+

+ If that email is registered, you'll receive a reset link shortly. + The link expires in one hour. +

+ {devResetUrl && ( +
+

+ Dev mode — copy this link +

+

+ {devResetUrl} +

+
+ )} +
+ )} + +
+ + ← Back to sign in + +
+
+
+ ); +} diff --git a/src/app/(auth)/login/CredentialsLogin.tsx b/src/app/(auth)/login/CredentialsLogin.tsx new file mode 100644 index 0000000..587ca3f --- /dev/null +++ b/src/app/(auth)/login/CredentialsLogin.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export function CredentialsLogin() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + + try { + const res = await fetch(`${BASE_PATH}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error ?? "Sign in failed"); + setSubmitting(false); + return; + } + + // Route first-time users to the forced password change. + if (data.mustChangePassword) { + router.push("/change-password?first=1"); + } else { + router.push("/dashboard"); + } + router.refresh(); + } catch { + setError("Network error — please try again."); + setSubmitting(false); + } + } + + return ( +
+
+ + setEmail(e.target.value)} + className="h-9 text-sm" + placeholder="you@oliver.agency" + /> +
+
+
+ + + Forgot? + +
+ setPassword(e.target.value)} + className="h-9 text-sm" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index a327d73..c1ad34b 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { Suspense } from "react"; import { MsalLogin } from "./MsalLogin"; +import { CredentialsLogin } from "./CredentialsLogin"; import type { MsalLoginConfig } from "@/lib/msal-config"; export default async function LoginPage() { @@ -11,11 +12,15 @@ export default async function LoginPage() { redirect("/dashboard"); } - const msalConfig: MsalLoginConfig = { - clientId: process.env.AZURE_CLIENT_ID!, - tenantId: process.env.AZURE_TENANT_ID!, - redirectUri: process.env.AZURE_REDIRECT_URI!, - }; + const entraEnabled = process.env.NEXT_PUBLIC_AUTH_ENTRA_ENABLED === "true"; + + const msalConfig: MsalLoginConfig | null = entraEnabled + ? { + clientId: process.env.AZURE_CLIENT_ID!, + tenantId: process.env.AZURE_TENANT_ID!, + redirectUri: process.env.AZURE_REDIRECT_URI!, + } + : null; return (
@@ -28,10 +33,10 @@ export default async function LoginPage() {

- HP CG
Production
Tracker + Dow Jones
Studio
Tracker

- Pipeline management for CG production + Production pipeline for Dow Jones studio

@@ -59,15 +64,28 @@ export default async function LoginPage() { Sign in

- Use your Oliver or HP work account to continue. + Enter your email and password to continue.

-
- - - -
+ + + {entraEnabled && msalConfig && ( + <> +
+
+ + or + +
+
+
+ + + +
+ + )}

diff --git a/src/app/(auth)/reset-password/[token]/page.tsx b/src/app/(auth)/reset-password/[token]/page.tsx new file mode 100644 index 0000000..86a349a --- /dev/null +++ b/src/app/(auth)/reset-password/[token]/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { use, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +interface Props { + params: Promise<{ token: string }>; +} + +export default function ResetPasswordPage({ params }: Props) { + const { token } = use(params); + const router = useRouter(); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (newPassword !== confirmPassword) { + setError("Passwords don't match."); + return; + } + if (newPassword.length < 10) { + setError("Password must be at least 10 characters."); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`${BASE_PATH}/api/auth/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, newPassword }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error ?? "Reset failed"); + setSubmitting(false); + return; + } + router.push("/login?reset=1"); + } catch { + setError("Network error — please try again."); + setSubmitting(false); + } + } + + return ( +

+
+

+ Choose a new password +

+ +
+
+ + setNewPassword(e.target.value)} + className="h-9 text-sm" + /> +

+ Minimum 10 characters. +

+
+
+ + setConfirmPassword(e.target.value)} + className="h-9 text-sm" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +
+ + ← Back to sign in + +
+
+
+ ); +} diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..aa51403 --- /dev/null +++ b/src/app/api/auth/change-password/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/rbac/require-auth"; + +/** + * POST /api/auth/change-password + * + * Body: { currentPassword: string, newPassword: string } + * + * Lets an authenticated user change their own password. Required to + * complete the mustChangePassword flow that the seed admin + freshly + * invited users land in on first sign-in. + * + * If the user has no existing passwordHash (e.g. an SSO-seeded account + * that later opted into local auth), the `currentPassword` check is + * skipped — this covers first-time password setup. + */ + +const MIN_PASSWORD_LEN = 10; + +export async function POST(request: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + + const body = await request.json().catch(() => null); + const currentPassword = body?.currentPassword as string | undefined; + const newPassword = body?.newPassword as string | undefined; + + if (!newPassword) { + return NextResponse.json( + { error: "New password is required" }, + { status: 400 } + ); + } + + if (newPassword.length < MIN_PASSWORD_LEN) { + return NextResponse.json( + { error: `Password must be at least ${MIN_PASSWORD_LEN} characters` }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { passwordHash: true }, + }); + + if (user?.passwordHash) { + if (!currentPassword) { + return NextResponse.json( + { error: "Current password is required" }, + { status: 400 } + ); + } + const ok = await bcrypt.compare(currentPassword, user.passwordHash); + if (!ok) { + return NextResponse.json( + { error: "Current password is incorrect" }, + { status: 401 } + ); + } + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + + await prisma.user.update({ + where: { id: session.user.id }, + data: { + passwordHash, + mustChangePassword: false, + }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..29af308 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/auth/forgot-password + * + * Issues a single-use password-reset token for the given email. + * + * Always returns 200 — we don't leak whether an email is registered. + * When SMTP is configured the reset link is emailed; until it is, the + * reset URL is returned in the response so an admin can hand it over + * out-of-band. That falls back cleanly once email is wired up. + * + * Token: 32 bytes url-safe. Expires in 1 hour. + */ + +const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const email = (body?.email as string | undefined)?.trim().toLowerCase(); + + if (!email) { + return NextResponse.json( + { error: "Email is required" }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + // Soft-fail response always — don't leak enumerability. + const response = { ok: true as const }; + + if (!user) { + return NextResponse.json(response); + } + + const token = randomBytes(24).toString("base64url"); + const expires = new Date(Date.now() + TOKEN_TTL_MS); + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordResetToken: token, + passwordResetExpires: expires, + }, + }); + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const origin = + request.headers.get("origin") ?? + process.env.NEXT_PUBLIC_APP_URL ?? + ""; + const resetUrl = `${origin}${basePath}/reset-password/${token}`; + + // Placeholder for SMTP send — for now, log to the server console and + // return the URL in dev so an admin can copy/paste. + const devMode = process.env.NODE_ENV !== "production"; + console.info( + "[auth] Password reset issued", + JSON.stringify({ userId: user.id, expires: expires.toISOString() }) + ); + + return NextResponse.json({ + ...response, + // Only return the reset URL in dev — in prod we rely on SMTP so the + // token isn't inadvertently leaked to the requester. + ...(devMode ? { devResetUrl: resetUrl } : {}), + }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..ebbac33 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,90 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { randomUUID } from "crypto"; +import { cookies } from "next/headers"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/auth/login + * + * Local email + password authentication — the MVP path while Entra SSO is + * pending its redirect URI. Mirrors the existing MSAL session-cookie + * pattern in /api/auth/sso: verifies the password with bcrypt, creates an + * Auth.js-compatible DB session, sets the session cookie. The existing + * middleware + auth() calls don't need to change. + * + * Response: + * 200 { ok: true, mustChangePassword: boolean } + * 400 { error: "..." } — missing fields or user has no password set + * 401 { error: "..." } — bad credentials + */ + +const GENERIC_AUTH_ERROR = "Invalid email or password"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const email = (body?.email as string | undefined)?.trim().toLowerCase(); + const password = body?.password as string | undefined; + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + passwordHash: true, + mustChangePassword: true, + organizationId: true, + }, + }); + + // Constant-time-ish: always run a bcrypt compare even when the user + // doesn't exist, so response timing doesn't leak account existence. + const dummyHash = + "$2a$10$CwTycUXWue0Thq9StjUM0uJ8PqWn3WlxFlxxVvV8j/kHrXFNz2xKy"; + const hashToCompare = user?.passwordHash ?? dummyHash; + const passwordValid = await bcrypt.compare(password, hashToCompare); + + if (!user || !user.passwordHash || !passwordValid) { + return NextResponse.json({ error: GENERIC_AUTH_ERROR }, { status: 401 }); + } + + // Create a DB session matching PrismaAdapter.createSession format + const sessionToken = randomUUID(); + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + await prisma.session.create({ + data: { sessionToken, userId: user.id, expires }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + // Auth.js-compatible cookie so auth() reads it unchanged + const forwarded = request.headers.get("x-forwarded-proto"); + const isSecure = + forwarded === "https" || request.url.startsWith("https://"); + const cookieName = isSecure + ? "__Secure-authjs.session-token" + : "authjs.session-token"; + + const cookieStore = await cookies(); + cookieStore.set(cookieName, sessionToken, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + path: "/", + expires, + }); + + return NextResponse.json({ + ok: true, + mustChangePassword: user.mustChangePassword, + }); +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..39280fb --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +/** + * POST /api/auth/reset-password + * + * Body: { token: string, newPassword: string } + * + * Validates the reset token, bcrypt-hashes the new password, stores it, + * clears the token and flips mustChangePassword=false. Also revokes all + * existing sessions for the user so anyone with a stolen cookie is + * logged out. + * + * Responses: + * 200 { ok: true } + * 400 { error } — missing fields, weak password, invalid/expired token + */ + +const MIN_PASSWORD_LEN = 10; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const token = body?.token as string | undefined; + const newPassword = body?.newPassword as string | undefined; + + if (!token || !newPassword) { + return NextResponse.json( + { error: "Token and new password are required" }, + { status: 400 } + ); + } + + if (newPassword.length < MIN_PASSWORD_LEN) { + return NextResponse.json( + { error: `Password must be at least ${MIN_PASSWORD_LEN} characters` }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { passwordResetToken: token }, + select: { id: true, passwordResetExpires: true }, + }); + + if ( + !user || + !user.passwordResetExpires || + user.passwordResetExpires < new Date() + ) { + return NextResponse.json( + { error: "Reset link is invalid or expired" }, + { status: 400 } + ); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + mustChangePassword: false, + passwordResetToken: null, + passwordResetExpires: null, + }, + }), + // Nuke any existing sessions so a leaked cookie can't linger after + // a password reset. + prisma.session.deleteMany({ where: { userId: user.id } }), + ]); + + return NextResponse.json({ ok: true }); +} diff --git a/src/middleware.ts b/src/middleware.ts index 17693ef..bc126f6 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -33,7 +33,10 @@ export function middleware(request: NextRequest) { } } - const isAuthPage = pathname.startsWith("/login"); + const isAuthPage = + pathname.startsWith("/login") || + pathname.startsWith("/forgot-password") || + pathname.startsWith("/reset-password"); const isPendingPage = pathname.startsWith("/pending"); const isApiAuth = pathname.startsWith("/api/auth"); const isHealthCheck = pathname === "/api/health";