feat(05-02): portal authentication via subscriber account number
- Add passwordHash field to Subscriber model (nullable for existing subscribers) - Add portal-credentials NextAuth provider (accountNumber + password + tenantId) - Persist subscriberId in JWT token and session for portal user identification - Extend next-auth types with optional subscriberId on Session, User, and JWT - Exclude /portal/login and /api/portal/auth from middleware auth requirement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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)).*)",
|
||||
],
|
||||
};
|
||||
|
||||
6
src/types/next-auth.d.ts
vendored
6
src/types/next-auth.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user