admin login fixed.
Build & Push / Pipeline Tests (push) Failing after 1m34s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Failing after 43s
Test / API Integration Tests (push) Successful in 1m8s
Build & Push / Pipeline Tests (push) Failing after 1m34s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Failing after 43s
Test / API Integration Tests (push) Successful in 1m8s
This commit is contained in:
@@ -85,6 +85,25 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
|
||||
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
|
||||
}
|
||||
|
||||
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
|
||||
const invoices = account.invoices ?? []
|
||||
const creditBalances = account.creditBalances ?? []
|
||||
const openBalance = invoices
|
||||
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
|
||||
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
|
||||
const paidBalance = invoices
|
||||
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
|
||||
.reduce((sum: number, invoice: any) => sum + (invoice.amountPaid ?? 0), 0)
|
||||
const creditBalance = creditBalances.reduce((sum: number, item: any) => sum + (item.balanceAmount ?? 0), 0)
|
||||
|
||||
return {
|
||||
...account,
|
||||
openBalance,
|
||||
paidBalance,
|
||||
creditBalance,
|
||||
}
|
||||
}
|
||||
|
||||
async function createBillingEvent(tx: any, data: {
|
||||
billingAccountId?: string | null
|
||||
invoiceId?: string | null
|
||||
@@ -455,20 +474,7 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
|
||||
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
|
||||
}
|
||||
|
||||
const data = (accounts as any[]).map((account) => {
|
||||
const openBalance = (account.invoices as any[])
|
||||
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
|
||||
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
|
||||
const paidBalance = (account.invoices as any[])
|
||||
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
|
||||
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
|
||||
return {
|
||||
...account,
|
||||
openBalance,
|
||||
paidBalance,
|
||||
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
|
||||
}
|
||||
})
|
||||
const data = (accounts as any[]).map((account) => withBillingAccountBalances(account))
|
||||
|
||||
return { data, total, stats }
|
||||
}
|
||||
@@ -513,7 +519,7 @@ export async function getBillingAccountDetail(companyId: string) {
|
||||
})
|
||||
|
||||
if (!account) throw new NotFoundError('Billing account not found')
|
||||
return account
|
||||
return withBillingAccountBalances(account)
|
||||
}
|
||||
|
||||
export async function updateBillingAccount(
|
||||
|
||||
@@ -564,20 +564,126 @@ export async function listNotificationsPage(query: {
|
||||
page: number
|
||||
pageSize: number
|
||||
}) {
|
||||
const where: any = {}
|
||||
if (query.channel) where.channel = query.channel
|
||||
if (query.status) where.status = query.status
|
||||
if (query.companyId) where.companyId = query.companyId
|
||||
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ'])
|
||||
const legacyWhere: any = {}
|
||||
if (query.channel) legacyWhere.channel = query.channel
|
||||
if (query.status && legacyStatuses.has(query.status)) {
|
||||
legacyWhere.status = query.status
|
||||
} else if (query.status) {
|
||||
legacyWhere.id = '__delivery_status_only__'
|
||||
}
|
||||
if (query.companyId) legacyWhere.companyId = query.companyId
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
const deliveryWhere: any = {}
|
||||
if (query.channel) deliveryWhere.channel = query.channel
|
||||
if (query.companyId) {
|
||||
deliveryWhere.notificationRecipient = {
|
||||
notificationEvent: { companyId: query.companyId },
|
||||
}
|
||||
}
|
||||
if (query.status === 'READ') {
|
||||
deliveryWhere.notificationRecipient = {
|
||||
...(deliveryWhere.notificationRecipient ?? {}),
|
||||
readAt: { not: null },
|
||||
}
|
||||
} else if (query.status) {
|
||||
deliveryWhere.status = query.status
|
||||
deliveryWhere.notificationRecipient = {
|
||||
...(deliveryWhere.notificationRecipient ?? {}),
|
||||
readAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
const take = query.page * query.pageSize
|
||||
|
||||
const [legacyNotifications, deliveryNotifications, legacyTotal, deliveryTotal] = await Promise.all([
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
where: legacyWhere,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
include: { company: { select: { name: true } } },
|
||||
take,
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({ where }),
|
||||
prisma.notificationDelivery.findMany({
|
||||
where: deliveryWhere,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
include: {
|
||||
notificationRecipient: {
|
||||
include: {
|
||||
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
notificationEvent: {
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({ where: legacyWhere }),
|
||||
prisma.notificationDelivery.count({ where: deliveryWhere }),
|
||||
])
|
||||
return { data, total }
|
||||
|
||||
const legacyRows = legacyNotifications.map((notification: any) => {
|
||||
const recipient = notification.employee ?? notification.renter ?? null
|
||||
return {
|
||||
id: `legacy:${notification.id}`,
|
||||
notificationId: notification.id,
|
||||
deliveryId: null,
|
||||
source: 'LEGACY',
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
channel: notification.channel,
|
||||
status: notification.status,
|
||||
locale: notification.locale,
|
||||
sentAt: notification.sentAt,
|
||||
createdAt: notification.createdAt,
|
||||
company: notification.company,
|
||||
companyId: notification.companyId,
|
||||
recipientType: notification.employeeId ? 'EMPLOYEE' : notification.renterId ? 'RENTER' : null,
|
||||
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
|
||||
recipientEmail: recipient?.email ?? null,
|
||||
employeeId: notification.employeeId,
|
||||
renterId: notification.renterId,
|
||||
}
|
||||
})
|
||||
|
||||
const deliveryRows = deliveryNotifications.map((delivery: any) => {
|
||||
const recipientRecord = delivery.notificationRecipient
|
||||
const event = recipientRecord.notificationEvent
|
||||
const recipient = recipientRecord.employee ?? recipientRecord.renter ?? null
|
||||
return {
|
||||
id: `delivery:${delivery.id}`,
|
||||
notificationId: event.id,
|
||||
deliveryId: delivery.id,
|
||||
source: 'DELIVERY',
|
||||
type: event.type,
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
channel: delivery.channel,
|
||||
status: recipientRecord.readAt ? 'READ' : delivery.status,
|
||||
locale: event.locale,
|
||||
sentAt: delivery.sentAt ?? delivery.deliveredAt ?? delivery.lastAttemptAt,
|
||||
createdAt: delivery.createdAt,
|
||||
company: event.company,
|
||||
companyId: event.companyId,
|
||||
recipientType: recipientRecord.recipientType,
|
||||
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
|
||||
recipientEmail: recipient?.email ?? null,
|
||||
employeeId: recipientRecord.employeeId,
|
||||
renterId: recipientRecord.renterId,
|
||||
}
|
||||
})
|
||||
|
||||
const data = [...legacyRows, ...deliveryRows]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
|
||||
|
||||
return { data, total: legacyTotal + deliveryTotal }
|
||||
}
|
||||
|
||||
@@ -45,8 +45,12 @@ router.post('/auth/login', async (req, res, next) => {
|
||||
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
|
||||
const result = await service.login(email, password, totpCode, recoveryCode)
|
||||
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
||||
if ('totpRequired' in result) {
|
||||
clearSessionCookie(res, 'employee')
|
||||
return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
||||
}
|
||||
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
||||
clearSessionCookie(res, 'employee')
|
||||
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { notificationsQuerySchema } from './admin.schemas'
|
||||
|
||||
describe('admin notification schemas', () => {
|
||||
it('accepts valid notification filters with pagination defaults', () => {
|
||||
expect(notificationsQuerySchema.parse({
|
||||
channel: 'EMAIL',
|
||||
status: 'QUEUED',
|
||||
companyId: 'company_1',
|
||||
page: '2',
|
||||
})).toEqual({
|
||||
channel: 'EMAIL',
|
||||
status: 'QUEUED',
|
||||
companyId: 'company_1',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid notification enum filters before querying Prisma', () => {
|
||||
expect(notificationsQuerySchema.safeParse({ channel: 'FAX' }).success).toBe(false)
|
||||
expect(notificationsQuerySchema.safeParse({ status: 'BOUNCED' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -53,9 +53,12 @@ export const auditLogQuerySchema = z.object({
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
const notificationChannelSchema = z.enum(['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'])
|
||||
const notificationStatusSchema = z.enum(['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ'])
|
||||
|
||||
export const notificationsQuerySchema = z.object({
|
||||
channel: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
channel: notificationChannelSchema.optional(),
|
||||
status: notificationStatusSchema.optional(),
|
||||
companyId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
vi.mock('./admin.repo', () => ({
|
||||
findAdminByEmail: vi.fn(),
|
||||
setAdminPasswordReset: vi.fn(),
|
||||
updateAdminLastLogin: vi.fn(),
|
||||
createAuditLog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
const redisStore = new Map<string, string>()
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: {
|
||||
on: vi.fn(),
|
||||
get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)),
|
||||
set: vi.fn((key: string, value: string) => {
|
||||
redisStore.set(key, value)
|
||||
return Promise.resolve('OK')
|
||||
}),
|
||||
del: vi.fn((key: string) => {
|
||||
const deleted = redisStore.delete(key) ? 1 : 0
|
||||
return Promise.resolve(deleted)
|
||||
}),
|
||||
quit: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './admin.repo'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { forgotPassword } from './admin.service'
|
||||
import { forgotPassword, login } from './admin.service'
|
||||
|
||||
describe('admin.service forgotPassword', () => {
|
||||
const originalAdminUrl = process.env.ADMIN_URL
|
||||
const originalJwtSecret = process.env.JWT_SECRET
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
redisStore.clear()
|
||||
process.env.ADMIN_URL = 'http://localhost:3000/admin'
|
||||
process.env.JWT_SECRET = 'test-jwt-secret'
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env.ADMIN_URL = originalAdminUrl
|
||||
process.env.JWT_SECRET = originalJwtSecret
|
||||
})
|
||||
|
||||
it('sends the reset email to the canonical stored admin address', async () => {
|
||||
@@ -46,4 +72,55 @@ describe('admin.service forgotPassword', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends an email login code when admin 2FA is enabled', async () => {
|
||||
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
|
||||
id: 'admin_2',
|
||||
email: 'admin@example.test',
|
||||
firstName: 'Amal',
|
||||
lastName: 'Admin',
|
||||
role: 'SUPER_ADMIN',
|
||||
isActive: true,
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
totpEnabled: true,
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
} as any)
|
||||
|
||||
await expect(login('admin@example.test', 'password123')).resolves.toEqual({ totpRequired: true })
|
||||
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'admin@example.test',
|
||||
subject: 'Your RentalDriveGo admin login code',
|
||||
text: expect.stringMatching(/\b\d{6}\b/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the emailed admin login code in the 2FA field', async () => {
|
||||
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
|
||||
id: 'admin_3',
|
||||
email: 'admin3@example.test',
|
||||
firstName: 'Mina',
|
||||
lastName: 'Admin',
|
||||
role: 'SUPER_ADMIN',
|
||||
isActive: true,
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
totpEnabled: true,
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
} as any)
|
||||
|
||||
await login('admin3@example.test', 'password123')
|
||||
const emailText = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]?.text ?? ''
|
||||
const code = emailText.match(/\b\d{6}\b/)?.[0]
|
||||
|
||||
expect(code).toBeTruthy()
|
||||
const result = await login('admin3@example.test', 'password123', code)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
token: expect.any(String),
|
||||
admin: expect.objectContaining({ id: 'admin_3', email: 'admin3@example.test' }),
|
||||
}))
|
||||
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,17 @@ import { signActorToken } from '../../security/tokens'
|
||||
import qrcode from 'qrcode'
|
||||
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { redis } from '../../lib/redis'
|
||||
import * as presenter from './admin.presenter'
|
||||
import * as repo from './admin.repo'
|
||||
import * as billingService from './admin.billing.service'
|
||||
|
||||
const ADMIN_RESET_TTL_MINUTES = 60
|
||||
const ADMIN_RECOVERY_CODE_COUNT = 10
|
||||
const ADMIN_EMAIL_OTP_TTL_MINUTES = 10
|
||||
const ADMIN_EMAIL_OTP_TTL_SECONDS = ADMIN_EMAIL_OTP_TTL_MINUTES * 60
|
||||
|
||||
const pendingAdminEmailOtps = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
|
||||
function generateRecoveryCode() {
|
||||
@@ -57,6 +62,61 @@ function signAdminToken(adminId: string, last2faAt?: number) {
|
||||
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
|
||||
}
|
||||
|
||||
function generateAdminEmailOtp() {
|
||||
return crypto.randomInt(100000, 1000000).toString()
|
||||
}
|
||||
|
||||
function adminEmailOtpKey(adminId: string) {
|
||||
return `admin:email-otp:${adminId}`
|
||||
}
|
||||
|
||||
async function sendAdminEmailOtp(admin: { id: string; email: string; firstName?: string | null }) {
|
||||
const code = generateAdminEmailOtp()
|
||||
const codeHash = hashPublicAccessToken(code)
|
||||
pendingAdminEmailOtps.set(admin.id, {
|
||||
code: codeHash,
|
||||
expiresAt: Date.now() + ADMIN_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
|
||||
})
|
||||
await redis
|
||||
.set(adminEmailOtpKey(admin.id), codeHash, 'EX', ADMIN_EMAIL_OTP_TTL_SECONDS)
|
||||
.catch((err) => console.error('[AdminLoginEmailOtpRedisSet]', err?.message))
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: admin.email,
|
||||
subject: 'Your RentalDriveGo admin login code',
|
||||
html: `<p>Hi ${admin.firstName ?? 'Admin'},</p><p>Your admin login code is <strong>${code}</strong>.</p><p>It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
|
||||
text: `Hi ${admin.firstName ?? 'Admin'},\n\nYour admin login code is ${code}.\n\nIt expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.`,
|
||||
}).catch((err) => console.error('[AdminLoginEmailOtp]', err?.message))
|
||||
}
|
||||
|
||||
async function consumeAdminEmailOtp(adminId: string, code: string | undefined) {
|
||||
if (!code) return false
|
||||
const codeHash = hashPublicAccessToken(code.trim())
|
||||
const key = adminEmailOtpKey(adminId)
|
||||
const persistedHash = await redis
|
||||
.get(key)
|
||||
.catch((err) => {
|
||||
console.error('[AdminLoginEmailOtpRedisGet]', err?.message)
|
||||
return null
|
||||
})
|
||||
if (persistedHash) {
|
||||
if (persistedHash !== codeHash) return false
|
||||
await redis.del(key).catch((err) => console.error('[AdminLoginEmailOtpRedisDel]', err?.message))
|
||||
pendingAdminEmailOtps.delete(adminId)
|
||||
return true
|
||||
}
|
||||
|
||||
const pending = pendingAdminEmailOtps.get(adminId)
|
||||
if (!pending) return false
|
||||
if (pending.expiresAt <= Date.now()) {
|
||||
pendingAdminEmailOtps.delete(adminId)
|
||||
return false
|
||||
}
|
||||
if (pending.code !== codeHash) return false
|
||||
pendingAdminEmailOtps.delete(adminId)
|
||||
return true
|
||||
}
|
||||
|
||||
function toAuditJson<T>(value: T) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
@@ -81,16 +141,20 @@ export async function login(email: string, password: string, totpCode?: string,
|
||||
if (!valid) return null
|
||||
|
||||
if (admin.totpEnabled) {
|
||||
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
|
||||
if (!totpCode && !recoveryCode) {
|
||||
await sendAdminEmailOtp(admin)
|
||||
return { totpRequired: true } as const
|
||||
}
|
||||
|
||||
const validTotp = totpCode
|
||||
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
||||
: false
|
||||
const validRecoveryCode = !validTotp && recoveryCode
|
||||
const validEmailOtp = !validTotp && await consumeAdminEmailOtp(admin.id, totpCode)
|
||||
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
|
||||
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
||||
: false
|
||||
|
||||
if (!validTotp && !validRecoveryCode) {
|
||||
if (!validTotp && !validEmailOtp && !validRecoveryCode) {
|
||||
return { invalidTotp: true } as const
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(employeeLoginSchema, req)
|
||||
const result = await service.login(body)
|
||||
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
|
||||
if ('token' in result) {
|
||||
clearSessionCookie(res, 'admin')
|
||||
setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
|
||||
}
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user