feat: add authentication, user management, and admin features
Added comprehensive authentication and authorization system: Authentication System: - Better Auth integration with session management - Login/logout pages and API routes - Middleware for route protection - Auth utilities and client libraries User Management: - User list, detail, and invite pages - User API endpoints (CRUD operations) - Session management for users - Profile settings page Role-Based Access Control: - Role management pages (list, create, edit) - Permission system with granular controls - Role assignment to users - Role API endpoints Admin Features: - Audit log page for tracking system events - Admin settings page - Audit service for logging user actions Additional Features: - Quotes management pages and components - SalesBldr API integration - Email service for notifications Configuration & Documentation: - Updated docker-compose.yml - MCP server configuration (mcp.json) - CVE-2025-55182 security review documentation - Standards guide and PRD documents - Re-enabling authentication documentation Database Migrations: - 012: Auth tables (users, sessions, accounts, verifications) - 013: Role tables (roles, permissions, role_permissions, user_roles) - 014: Admin settings table UI Updates: - Updated dashboard layout - Enhanced app layout with auth integration
This commit is contained in:
parent
d8e6931b85
commit
9f912aed24
68 changed files with 7651 additions and 3 deletions
40
lib/auth-client.ts
Normal file
40
lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { createAuthClient } from "better-auth/react";
|
||||
import { magicLinkClient, twoFactorClient, adminClient } from "better-auth/client/plugins";
|
||||
import { ac, adminRole, userRole, superAdminRole } from "./permissions";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "",
|
||||
plugins: [
|
||||
// Magic link client
|
||||
magicLinkClient(),
|
||||
|
||||
// Two-factor authentication client
|
||||
twoFactorClient({
|
||||
onTwoFactorRedirect() {
|
||||
window.location.href = "/auth/2fa";
|
||||
},
|
||||
}),
|
||||
|
||||
// Admin client with RBAC
|
||||
adminClient({
|
||||
ac,
|
||||
roles: {
|
||||
"super-admin": superAdminRole,
|
||||
admin: adminRole,
|
||||
user: userRole,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Export commonly used hooks and methods
|
||||
export const {
|
||||
signIn,
|
||||
signOut,
|
||||
useSession,
|
||||
getSession,
|
||||
} = authClient;
|
||||
|
||||
// Type exports
|
||||
export type Session = typeof authClient.$Infer.Session;
|
||||
export type User = typeof authClient.$Infer.Session.user;
|
||||
169
lib/auth-utils.ts
Normal file
169
lib/auth-utils.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { hasPermission, type Permission } from "./permissions";
|
||||
|
||||
// Extended user type with custom fields
|
||||
type UserWithRole = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
requires_setup?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current session from the request headers
|
||||
* Use this in API routes and server components
|
||||
*/
|
||||
export async function getSession() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication for an API route
|
||||
* Returns the session if authenticated, or a 401 response if not
|
||||
*/
|
||||
export async function requireAuth() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
session: null,
|
||||
error: NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { session, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require specific permissions for an API route
|
||||
* Returns the session if authorized, or a 403 response if not
|
||||
*/
|
||||
export async function requirePermission(
|
||||
resource: Permission["resource"],
|
||||
action: string
|
||||
) {
|
||||
const { session, error } = await requireAuth();
|
||||
|
||||
if (error) {
|
||||
return { session: null, error };
|
||||
}
|
||||
|
||||
const userRole = (session!.user as UserWithRole).role || "user";
|
||||
|
||||
if (!hasPermission(userRole, resource, action)) {
|
||||
return {
|
||||
session: null,
|
||||
error: NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { session, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin or super-admin role
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const { session, error } = await requireAuth();
|
||||
|
||||
if (error) {
|
||||
return { session: null, error };
|
||||
}
|
||||
|
||||
const userRole = (session!.user as UserWithRole).role || "user";
|
||||
|
||||
if (userRole !== "admin" && userRole !== "super-admin") {
|
||||
return {
|
||||
session: null,
|
||||
error: NextResponse.json(
|
||||
{ error: "Forbidden - Admin access required" },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { session, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require super-admin role
|
||||
*/
|
||||
export async function requireSuperAdmin() {
|
||||
const { session, error } = await requireAuth();
|
||||
|
||||
if (error) {
|
||||
return { session: null, error };
|
||||
}
|
||||
|
||||
const userRole = (session!.user as UserWithRole).role || "user";
|
||||
|
||||
if (userRole !== "super-admin") {
|
||||
return {
|
||||
session: null,
|
||||
error: NextResponse.json(
|
||||
{ error: "Forbidden - Super admin access required" },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { session, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a specific permission
|
||||
* Use this in server components for conditional rendering
|
||||
*/
|
||||
export async function checkPermission(
|
||||
resource: Permission["resource"],
|
||||
action: string
|
||||
): Promise<boolean> {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRole = (session.user as UserWithRole).role || "user";
|
||||
return hasPermission(userRole, resource, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin or super-admin
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRole = (session.user as UserWithRole).role || "user";
|
||||
return userRole === "admin" || userRole === "super-admin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a super-admin
|
||||
*/
|
||||
export async function isSuperAdmin(): Promise<boolean> {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (session.user as UserWithRole).role === "super-admin";
|
||||
}
|
||||
92
lib/auth.ts
Normal file
92
lib/auth.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { betterAuth } from "better-auth";
|
||||
import { magicLink, twoFactor, admin } from "better-auth/plugins";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { Pool } from "pg";
|
||||
import { sendMagicLinkEmail } from "./services/email";
|
||||
import { ac, adminRole, userRole, superAdminRole } from "./permissions";
|
||||
|
||||
// Create a PostgreSQL pool for Better Auth
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: pool,
|
||||
appName: "Pulse",
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
trustedOrigins: [
|
||||
"http://localhost:3100",
|
||||
"https://pulse.wulfconsulting.cloud"
|
||||
],
|
||||
|
||||
// Email & Password disabled - using magic link and Microsoft OAuth only
|
||||
emailAndPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: parseInt(process.env.SESSION_TIMEOUT_SECONDS || "86400"),
|
||||
updateAge: 60 * 60, // Update session every hour
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
||||
tenantId: process.env.MICROSOFT_TENANT_ID || "common",
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
// Magic link authentication
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url, token }) => {
|
||||
await sendMagicLinkEmail({ email, url, token });
|
||||
},
|
||||
expiresIn: 300, // 5 minutes
|
||||
}),
|
||||
|
||||
// Two-factor authentication
|
||||
twoFactor({
|
||||
issuer: "Pulse",
|
||||
}),
|
||||
|
||||
// Admin plugin with RBAC
|
||||
admin({
|
||||
ac,
|
||||
roles: {
|
||||
"super-admin": superAdminRole,
|
||||
admin: adminRole,
|
||||
user: userRole,
|
||||
},
|
||||
}),
|
||||
|
||||
// Next.js cookie handling - must be last
|
||||
nextCookies(),
|
||||
],
|
||||
|
||||
// User configuration
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
defaultValue: "user",
|
||||
},
|
||||
requires_setup: {
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
103
lib/bootstrap.ts
Normal file
103
lib/bootstrap.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if any users exist in the database
|
||||
*/
|
||||
export async function hasUsers(): Promise<boolean> {
|
||||
try {
|
||||
const result = await pool.query('SELECT COUNT(*) as count FROM "user"');
|
||||
return parseInt(result.rows[0].count) > 0;
|
||||
} catch (error) {
|
||||
// Table might not exist yet
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default super-admin user from environment variables
|
||||
*/
|
||||
export async function createDefaultAdmin(): Promise<boolean> {
|
||||
const email = process.env.DEFAULT_ADMIN_EMAIL;
|
||||
const name = process.env.DEFAULT_ADMIN_NAME || "System Administrator";
|
||||
|
||||
if (!email) {
|
||||
console.warn("DEFAULT_ADMIN_EMAIL not set, skipping admin creation");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM "user" WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
console.log("Default admin already exists");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the admin user
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO "user" (id, name, email, role, email_verified, requires_setup, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'super-admin', false, true, NOW(), NOW())`,
|
||||
[id, name, email]
|
||||
);
|
||||
|
||||
console.log(`Created default admin: ${email}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to create default admin:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bootstrap checks and setup
|
||||
*/
|
||||
export async function bootstrap(): Promise<void> {
|
||||
try {
|
||||
const usersExist = await hasUsers();
|
||||
|
||||
if (!usersExist) {
|
||||
console.log("No users found, running initial setup...");
|
||||
await createDefaultAdmin();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Bootstrap failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user requires setup (has requires_setup flag)
|
||||
*/
|
||||
export async function userRequiresSetup(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT requires_setup FROM "user" WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0]?.requires_setup === true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the requires_setup flag for a user
|
||||
*/
|
||||
export async function clearSetupFlag(userId: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE "user" SET requires_setup = false, updated_at = NOW() WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear setup flag:", error);
|
||||
}
|
||||
}
|
||||
97
lib/permissions.ts
Normal file
97
lib/permissions.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
|
||||
// Define all available permissions for resources
|
||||
export const statement = {
|
||||
// Ticket management
|
||||
tickets: ["create", "read", "update", "delete"],
|
||||
|
||||
// Configuration items
|
||||
configItems: ["create", "read", "update", "delete"],
|
||||
|
||||
// Admin panel access
|
||||
admin: ["access"],
|
||||
|
||||
// User management
|
||||
users: ["create", "read", "update", "delete", "invite", "ban"],
|
||||
|
||||
// Role management
|
||||
roles: ["create", "read", "update", "delete"],
|
||||
|
||||
// Audit log access
|
||||
auditLog: ["read"],
|
||||
|
||||
// Settings management
|
||||
settings: ["read", "update"],
|
||||
} as const;
|
||||
|
||||
// Create access control instance
|
||||
export const ac = createAccessControl(statement);
|
||||
|
||||
// Super Admin role - full access to everything
|
||||
export const superAdminRole = ac.newRole({
|
||||
tickets: ["create", "read", "update", "delete"],
|
||||
configItems: ["create", "read", "update", "delete"],
|
||||
admin: ["access"],
|
||||
users: ["create", "read", "update", "delete", "invite", "ban"],
|
||||
roles: ["create", "read", "update", "delete"],
|
||||
auditLog: ["read"],
|
||||
settings: ["read", "update"],
|
||||
});
|
||||
|
||||
// Admin role - access to admin panel and user management, but not role management
|
||||
export const adminRole = ac.newRole({
|
||||
tickets: ["create", "read", "update", "delete"],
|
||||
configItems: ["create", "read", "update", "delete"],
|
||||
admin: ["access"],
|
||||
users: ["create", "read", "update", "invite"],
|
||||
roles: ["read"],
|
||||
auditLog: ["read"],
|
||||
settings: ["read"],
|
||||
});
|
||||
|
||||
// User role - basic access
|
||||
export const userRole = ac.newRole({
|
||||
tickets: ["create", "read", "update"],
|
||||
configItems: ["read"],
|
||||
admin: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
auditLog: [],
|
||||
settings: [],
|
||||
});
|
||||
|
||||
// Helper function to check if a user has a specific permission
|
||||
export function hasPermission(
|
||||
userRole: string,
|
||||
resource: keyof typeof statement,
|
||||
action: string
|
||||
): boolean {
|
||||
const roles: Record<string, ReturnType<typeof ac.newRole>> = {
|
||||
"super-admin": superAdminRole,
|
||||
admin: adminRole,
|
||||
user: userRole as unknown as ReturnType<typeof ac.newRole>,
|
||||
};
|
||||
|
||||
const role = roles[userRole];
|
||||
if (!role) return false;
|
||||
|
||||
// Check if the role has the permission
|
||||
const permissions = role.statements[resource];
|
||||
if (!permissions) return false;
|
||||
|
||||
return (permissions as readonly string[]).includes(action);
|
||||
}
|
||||
|
||||
// Type for permission check
|
||||
export type Permission = {
|
||||
resource: keyof typeof statement;
|
||||
action: string;
|
||||
};
|
||||
|
||||
// Check multiple permissions
|
||||
export function hasPermissions(
|
||||
userRole: string,
|
||||
permissions: Permission[]
|
||||
): boolean {
|
||||
return permissions.every((p) => hasPermission(userRole, p.resource, p.action));
|
||||
}
|
||||
197
lib/services/audit.ts
Normal file
197
lib/services/audit.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export interface AuditLogEntry {
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
resourceId?: string;
|
||||
details?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
*/
|
||||
export async function log(entry: AuditLogEntry): Promise<void> {
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO "audit_log"
|
||||
(id, user_id, user_email, action, resource, resource_id, details, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
id,
|
||||
entry.userId || null,
|
||||
entry.userEmail || null,
|
||||
entry.action,
|
||||
entry.resource,
|
||||
entry.resourceId || null,
|
||||
entry.details ? JSON.stringify(entry.details) : null,
|
||||
entry.ipAddress || null,
|
||||
entry.userAgent || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to write audit log:", error);
|
||||
// Don't throw - audit logging should not break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions for common audit events
|
||||
export const audit = {
|
||||
log,
|
||||
|
||||
// Auth events
|
||||
signIn: (userId: string, email: string, ipAddress?: string, userAgent?: string) =>
|
||||
log({
|
||||
userId,
|
||||
userEmail: email,
|
||||
action: "sign_in",
|
||||
resource: "auth",
|
||||
ipAddress,
|
||||
userAgent,
|
||||
}),
|
||||
|
||||
signOut: (userId: string, email: string, ipAddress?: string) =>
|
||||
log({
|
||||
userId,
|
||||
userEmail: email,
|
||||
action: "sign_out",
|
||||
resource: "auth",
|
||||
ipAddress,
|
||||
}),
|
||||
|
||||
signInFailed: (email: string, reason: string, ipAddress?: string) =>
|
||||
log({
|
||||
userEmail: email,
|
||||
action: "sign_in_failed",
|
||||
resource: "auth",
|
||||
details: { reason },
|
||||
ipAddress,
|
||||
}),
|
||||
|
||||
// User management events
|
||||
userCreated: (actorId: string, actorEmail: string, targetUserId: string, targetEmail: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "create",
|
||||
resource: "user",
|
||||
resourceId: targetUserId,
|
||||
details: { targetEmail },
|
||||
}),
|
||||
|
||||
userUpdated: (actorId: string, actorEmail: string, targetUserId: string, changes: Record<string, unknown>) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "update",
|
||||
resource: "user",
|
||||
resourceId: targetUserId,
|
||||
details: { changes },
|
||||
}),
|
||||
|
||||
userDeleted: (actorId: string, actorEmail: string, targetUserId: string, targetEmail: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "delete",
|
||||
resource: "user",
|
||||
resourceId: targetUserId,
|
||||
details: { targetEmail },
|
||||
}),
|
||||
|
||||
userBanned: (actorId: string, actorEmail: string, targetUserId: string, reason?: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "ban",
|
||||
resource: "user",
|
||||
resourceId: targetUserId,
|
||||
details: { reason },
|
||||
}),
|
||||
|
||||
userUnbanned: (actorId: string, actorEmail: string, targetUserId: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "unban",
|
||||
resource: "user",
|
||||
resourceId: targetUserId,
|
||||
}),
|
||||
|
||||
userRoleChanged: (actorId: string, actorEmail: string, targetUserId: string, oldRole: string, newRole: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "role_change",
|
||||
resource: "user",
|
||||
resourceId: targetUserId,
|
||||
details: { oldRole, newRole },
|
||||
}),
|
||||
|
||||
// Role management events
|
||||
roleCreated: (actorId: string, actorEmail: string, roleId: string, roleName: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "create",
|
||||
resource: "role",
|
||||
resourceId: roleId,
|
||||
details: { roleName },
|
||||
}),
|
||||
|
||||
roleUpdated: (actorId: string, actorEmail: string, roleId: string, changes: Record<string, unknown>) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "update",
|
||||
resource: "role",
|
||||
resourceId: roleId,
|
||||
details: { changes },
|
||||
}),
|
||||
|
||||
roleDeleted: (actorId: string, actorEmail: string, roleId: string, roleName: string) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "delete",
|
||||
resource: "role",
|
||||
resourceId: roleId,
|
||||
details: { roleName },
|
||||
}),
|
||||
|
||||
// Settings events
|
||||
settingsUpdated: (actorId: string, actorEmail: string, setting: string, oldValue: unknown, newValue: unknown) =>
|
||||
log({
|
||||
userId: actorId,
|
||||
userEmail: actorEmail,
|
||||
action: "update",
|
||||
resource: "settings",
|
||||
resourceId: setting,
|
||||
details: { oldValue, newValue },
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Purge old audit logs based on retention policy
|
||||
*/
|
||||
export async function purgeOldLogs(retentionDays?: number): Promise<number> {
|
||||
const days = retentionDays || parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || "90");
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM "audit_log" WHERE timestamp < NOW() - INTERVAL '${days} days'`
|
||||
);
|
||||
return result.rowCount || 0;
|
||||
} catch (error) {
|
||||
console.error("Failed to purge audit logs:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
165
lib/services/email.ts
Normal file
165
lib/services/email.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import * as nodemailer from "nodemailer";
|
||||
|
||||
// SMTP configuration from environment variables
|
||||
const smtpConfig = {
|
||||
host: process.env.SMTP_HOST || "smtp.example.com",
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
secure: process.env.SMTP_PORT === "465", // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || "",
|
||||
pass: process.env.SMTP_PASSWORD || "",
|
||||
},
|
||||
};
|
||||
|
||||
const fromAddress = process.env.SMTP_FROM || "noreply@example.com";
|
||||
|
||||
// Create reusable transporter
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter(): nodemailer.Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport(smtpConfig);
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Email templates
|
||||
interface MagicLinkEmailParams {
|
||||
email: string;
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
email,
|
||||
url,
|
||||
}: MagicLinkEmailParams): Promise<void> {
|
||||
const transport = getTransporter();
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in to Pulse</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<h2 style="color: #333; margin-top: 0;">Sign in to your account</h2>
|
||||
<p>Click the button below to sign in to Pulse. This link will expire in 5 minutes.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Sign in to Pulse
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
Sign in to Pulse
|
||||
|
||||
Click the link below to sign in to your account. This link will expire in 5 minutes.
|
||||
|
||||
${url}
|
||||
|
||||
If you didn't request this email, you can safely ignore it.
|
||||
`;
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
to: email,
|
||||
subject: "Sign in to Pulse",
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
interface InvitationEmailParams {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function sendInvitationEmail({
|
||||
email,
|
||||
inviterName,
|
||||
url,
|
||||
}: InvitationEmailParams): Promise<void> {
|
||||
const transport = getTransporter();
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're invited to Pulse</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<h2 style="color: #333; margin-top: 0;">You're invited!</h2>
|
||||
<p><strong>${inviterName}</strong> has invited you to join Pulse.</p>
|
||||
<p>Click the button below to accept the invitation and set up your account.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
You're invited to Pulse!
|
||||
|
||||
${inviterName} has invited you to join Pulse.
|
||||
|
||||
Click the link below to accept the invitation and set up your account:
|
||||
|
||||
${url}
|
||||
|
||||
If you weren't expecting this invitation, you can safely ignore this email.
|
||||
`;
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
to: email,
|
||||
subject: "You're invited to Pulse",
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify SMTP connection
|
||||
export async function verifyEmailConnection(): Promise<boolean> {
|
||||
try {
|
||||
const transport = getTransporter();
|
||||
await transport.verify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("SMTP connection verification failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
208
lib/services/salesbldr-client.ts
Normal file
208
lib/services/salesbldr-client.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
interface SalesBldrConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface QuoteItem {
|
||||
id: string;
|
||||
category?: object;
|
||||
cost?: number;
|
||||
mpn?: string;
|
||||
markup?: number;
|
||||
deliveryDate?: string;
|
||||
discount?: number;
|
||||
unit?: string;
|
||||
term?: string;
|
||||
name: string;
|
||||
price?: number;
|
||||
quantity: number;
|
||||
shortDescription?: string;
|
||||
}
|
||||
|
||||
interface Quote {
|
||||
id: string;
|
||||
title: string;
|
||||
number: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
sentAt?: string;
|
||||
expiresAt?: string;
|
||||
approvedAt?: string;
|
||||
company?: {
|
||||
id: string;
|
||||
name: string;
|
||||
externalIdentifier?: string;
|
||||
};
|
||||
contact?: object;
|
||||
owner?: object;
|
||||
items?: QuoteItem[];
|
||||
opportunity?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface QuoteSearchResponse {
|
||||
results: Quote[];
|
||||
total: number;
|
||||
filters?: any[];
|
||||
sortOptions?: any[];
|
||||
}
|
||||
|
||||
export class SalesBldrClient {
|
||||
private config: SalesBldrConfig;
|
||||
|
||||
constructor(config?: SalesBldrConfig) {
|
||||
this.config = config || {
|
||||
apiUrl: process.env.SALESBLDR_API_URL || '',
|
||||
apiKey: process.env.SALESBLDR_API_KEY || ''
|
||||
};
|
||||
}
|
||||
|
||||
private ensureConfig() {
|
||||
if (!this.config.apiUrl || !this.config.apiKey) {
|
||||
throw new Error('SalesBldr API URL and API Key are required');
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
this.ensureConfig();
|
||||
const url = `${this.config.apiUrl}${endpoint}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'api-key': this.config.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`SalesBldr API error: ${response.status} ${response.statusText} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getQuotes(params?: {
|
||||
sort?: string;
|
||||
filters?: string;
|
||||
query?: string;
|
||||
size?: number;
|
||||
from?: number;
|
||||
}): Promise<QuoteSearchResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.sort) queryParams.append('sort', params.sort);
|
||||
if (params?.filters) queryParams.append('filters', params.filters);
|
||||
if (params?.query) queryParams.append('query', params.query);
|
||||
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||
|
||||
const endpoint = `/public-api/quote${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
return this.request<QuoteSearchResponse>(endpoint);
|
||||
}
|
||||
|
||||
async getOpenQuotes(size: number = 10): Promise<QuoteSearchResponse> {
|
||||
return this.getQuotes({
|
||||
filters: 'status:sent|draft',
|
||||
sort: '-createdAt',
|
||||
size
|
||||
});
|
||||
}
|
||||
|
||||
async getApprovedQuotes(size: number = 10): Promise<Quote[]> {
|
||||
const endpoint = `/public-api/quote/approved`;
|
||||
return this.request<Quote[]>(endpoint);
|
||||
}
|
||||
|
||||
async getOpportunities(params?: {
|
||||
sort?: string;
|
||||
filters?: string;
|
||||
query?: string;
|
||||
size?: number;
|
||||
from?: number;
|
||||
}): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.sort) queryParams.append('sort', params.sort);
|
||||
if (params?.filters) queryParams.append('filters', params.filters);
|
||||
if (params?.query) queryParams.append('query', params.query);
|
||||
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||
|
||||
const endpoint = `/public-api/opportunity${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
return this.request(endpoint);
|
||||
}
|
||||
|
||||
async getProducts(params?: {
|
||||
includeBundles?: boolean;
|
||||
sort?: string;
|
||||
filters?: string;
|
||||
query?: string;
|
||||
size?: number;
|
||||
from?: number;
|
||||
}): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.includeBundles !== undefined) queryParams.append('includeBundles', params.includeBundles.toString());
|
||||
if (params?.sort) queryParams.append('sort', params.sort);
|
||||
if (params?.filters) queryParams.append('filters', params.filters);
|
||||
if (params?.query) queryParams.append('query', params.query);
|
||||
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||
|
||||
const endpoint = `/public-api/product${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
return this.request(endpoint);
|
||||
}
|
||||
|
||||
async getCompanies(params?: {
|
||||
sort?: string;
|
||||
filters?: string;
|
||||
query?: string;
|
||||
size?: number;
|
||||
from?: number;
|
||||
}): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.sort) queryParams.append('sort', params.sort);
|
||||
if (params?.filters) queryParams.append('filters', params.filters);
|
||||
if (params?.query) queryParams.append('query', params.query);
|
||||
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||
|
||||
const endpoint = `/public-api/company${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
return this.request(endpoint);
|
||||
}
|
||||
|
||||
async getContacts(params?: {
|
||||
sort?: string;
|
||||
filters?: string;
|
||||
query?: string;
|
||||
size?: number;
|
||||
from?: number;
|
||||
}): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.sort) queryParams.append('sort', params.sort);
|
||||
if (params?.filters) queryParams.append('filters', params.filters);
|
||||
if (params?.query) queryParams.append('query', params.query);
|
||||
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||
|
||||
const endpoint = `/public-api/contact${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
return this.request(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
export const salesBldrClient = new SalesBldrClient();
|
||||
Loading…
Add table
Add a link
Reference in a new issue