diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef714dc..b2b9764 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -329,6 +329,8 @@ model Subscriber { cancelledAt DateTime? /// Per-subscriber auto-suspend override (null = use TenantSettings.autoSuspendDays) autoSuspendDays Int? + /// Hashed password for portal login — nullable because existing subscribers may not have portal access + passwordHash String? notes String? /// Overpayment credit balance — always updated atomically with journal entries. /// This is NOT a stored ledger balance; it tracks credits for the FIFO allocation diff --git a/src/lib/auth-options.ts b/src/lib/auth-options.ts index 4c9f800..31c308d 100644 --- a/src/lib/auth-options.ts +++ b/src/lib/auth-options.ts @@ -2,7 +2,7 @@ import { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; -import { TenantStatus } from "@prisma/client"; +import { Role, TenantStatus } from "@prisma/client"; export const authOptions: NextAuthOptions = { session: { @@ -13,7 +13,9 @@ export const authOptions: NextAuthOptions = { signIn: "/login", }, providers: [ + // Staff credentials provider (existing) CredentialsProvider({ + id: "credentials", name: "credentials", credentials: { email: { label: "Email", type: "email" }, @@ -68,6 +70,59 @@ export const authOptions: NextAuthOptions = { }; }, }), + + // Portal credentials provider — subscriber login via account number + CredentialsProvider({ + id: "portal-credentials", + name: "portal-credentials", + credentials: { + accountNumber: { label: "Account Number", type: "text" }, + password: { label: "Password", type: "password" }, + tenantId: { label: "Tenant ID", type: "text" }, + }, + async authorize(credentials) { + if ( + !credentials?.accountNumber || + !credentials?.password || + !credentials?.tenantId + ) { + return null; + } + + // Lookup subscriber by tenantId + accountNumber with a non-null passwordHash + const subscriber = await prisma.subscriber.findFirst({ + where: { + tenantId: credentials.tenantId, + accountNumber: credentials.accountNumber, + passwordHash: { not: null }, + }, + }); + + if (!subscriber || !subscriber.passwordHash) { + return null; + } + + const passwordValid = await bcrypt.compare( + credentials.password, + subscriber.passwordHash + ); + + if (!passwordValid) { + return null; + } + + return { + id: subscriber.id, + email: subscriber.email || subscriber.accountNumber, + tenantId: subscriber.tenantId, + roles: [Role.CLIENT], + isSuperAdmin: false, + firstName: subscriber.firstName, + lastName: subscriber.lastName, + subscriberId: subscriber.id, + }; + }, + }), ], callbacks: { async jwt({ token, user }) { @@ -80,6 +135,8 @@ export const authOptions: NextAuthOptions = { token.isSuperAdmin = user.isSuperAdmin; token.firstName = user.firstName; token.lastName = user.lastName; + // Persist subscriberId for portal sessions + token.subscriberId = user.subscriberId; } return token; }, @@ -93,6 +150,7 @@ export const authOptions: NextAuthOptions = { isSuperAdmin: token.isSuperAdmin, firstName: token.firstName, lastName: token.lastName, + subscriberId: token.subscriberId, }; return session; }, diff --git a/src/middleware.ts b/src/middleware.ts index b7bdede..a41780e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -45,6 +45,6 @@ export const config = { * - /favicon.ico, /robots.txt, /sitemap.xml (static files) * - Image files (.png, .jpg, .jpeg, .gif, .webp, .svg, .ico) */ - "/((?!login|signup|api/auth|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico)).*)", + "/((?!login|signup|portal/login|api/auth|api/portal/auth|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico)).*)", ], }; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 9aa9758..5e99594 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -12,6 +12,8 @@ declare module "next-auth" { isSuperAdmin: boolean; firstName: string; lastName: string; + /** Present only for portal (subscriber) sessions */ + subscriberId?: string; }; } @@ -23,6 +25,8 @@ declare module "next-auth" { isSuperAdmin: boolean; firstName: string; lastName: string; + /** Present only for portal (subscriber) sessions */ + subscriberId?: string; } } @@ -35,5 +39,7 @@ declare module "next-auth/jwt" { isSuperAdmin: boolean; firstName: string; lastName: string; + /** Present only for portal (subscriber) sessions */ + subscriberId?: string; } }