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?
|
cancelledAt DateTime?
|
||||||
/// Per-subscriber auto-suspend override (null = use TenantSettings.autoSuspendDays)
|
/// Per-subscriber auto-suspend override (null = use TenantSettings.autoSuspendDays)
|
||||||
autoSuspendDays Int?
|
autoSuspendDays Int?
|
||||||
|
/// Hashed password for portal login — nullable because existing subscribers may not have portal access
|
||||||
|
passwordHash String?
|
||||||
notes String?
|
notes String?
|
||||||
/// Overpayment credit balance — always updated atomically with journal entries.
|
/// Overpayment credit balance — always updated atomically with journal entries.
|
||||||
/// This is NOT a stored ledger balance; it tracks credits for the FIFO allocation
|
/// 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 CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { TenantStatus } from "@prisma/client";
|
import { Role, TenantStatus } from "@prisma/client";
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
session: {
|
session: {
|
||||||
@@ -13,7 +13,9 @@ export const authOptions: NextAuthOptions = {
|
|||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
|
// Staff credentials provider (existing)
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
|
id: "credentials",
|
||||||
name: "credentials",
|
name: "credentials",
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
@@ -80,6 +135,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
token.isSuperAdmin = user.isSuperAdmin;
|
token.isSuperAdmin = user.isSuperAdmin;
|
||||||
token.firstName = user.firstName;
|
token.firstName = user.firstName;
|
||||||
token.lastName = user.lastName;
|
token.lastName = user.lastName;
|
||||||
|
// Persist subscriberId for portal sessions
|
||||||
|
token.subscriberId = user.subscriberId;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
@@ -93,6 +150,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
isSuperAdmin: token.isSuperAdmin,
|
isSuperAdmin: token.isSuperAdmin,
|
||||||
firstName: token.firstName,
|
firstName: token.firstName,
|
||||||
lastName: token.lastName,
|
lastName: token.lastName,
|
||||||
|
subscriberId: token.subscriberId,
|
||||||
};
|
};
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,6 @@ export const config = {
|
|||||||
* - /favicon.ico, /robots.txt, /sitemap.xml (static files)
|
* - /favicon.ico, /robots.txt, /sitemap.xml (static files)
|
||||||
* - Image files (.png, .jpg, .jpeg, .gif, .webp, .svg, .ico)
|
* - 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;
|
isSuperAdmin: boolean;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
/** Present only for portal (subscriber) sessions */
|
||||||
|
subscriberId?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ declare module "next-auth" {
|
|||||||
isSuperAdmin: boolean;
|
isSuperAdmin: boolean;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
/** Present only for portal (subscriber) sessions */
|
||||||
|
subscriberId?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,5 +39,7 @@ declare module "next-auth/jwt" {
|
|||||||
isSuperAdmin: boolean;
|
isSuperAdmin: boolean;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
/** Present only for portal (subscriber) sessions */
|
||||||
|
subscriberId?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user