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:
kevin-asprec
2026-03-05 17:22:52 +08:00
parent 7fabb77ee4
commit 539564dbd4
4 changed files with 68 additions and 2 deletions

View File

@@ -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

View File

@@ -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;
},

View File

@@ -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)).*)",
],
};

View File

@@ -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;
}
}