diff --git a/apps/bff/src/core/logging/logging.module.ts b/apps/bff/src/core/logging/logging.module.ts index e596d60d..f96eae41 100644 --- a/apps/bff/src/core/logging/logging.module.ts +++ b/apps/bff/src/core/logging/logging.module.ts @@ -1,12 +1,13 @@ import { Global, Module } from "@nestjs/common"; -import { LoggerModule } from "nestjs-pino"; -import type { Options as PinoHttpOptions } from "pino-http"; +import { LoggerModule, type Params } from "nestjs-pino"; +import type { IncomingMessage, ServerResponse } from "http"; const prettyLogsEnabled = process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production"; // Build pinoHttp config - extracted to avoid type issues with exactOptionalPropertyTypes -const pinoHttpConfig: PinoHttpOptions = { +// Using NonNullable to extract the pinoHttp type from Params +const pinoHttpConfig: NonNullable = { level: process.env["LOG_LEVEL"] || "info", name: process.env["APP_NAME"] || "customer-portal-bff", /** @@ -18,13 +19,13 @@ const pinoHttpConfig: PinoHttpOptions = { * This keeps production logs focused on actionable events while still * allowing full request logging by setting LOG_LEVEL=debug. */ - customLogLevel: (_req, res, err) => { + customLogLevel: (_req: IncomingMessage, res: ServerResponse, err: Error | undefined) => { if (err || (res?.statusCode && res.statusCode >= 500)) return "error"; if (res?.statusCode && res.statusCode >= 400) return "warn"; return "debug"; }, autoLogging: { - ignore: req => { + ignore: (req: IncomingMessage) => { const url = req.url || ""; return ( url.includes("/health") || diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index e76cbbde..d1e188be 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -1,35 +1,12 @@ import { Injectable, Inject } from "@nestjs/common"; -import { Prisma } from "@prisma/client"; +import { Prisma, AuditAction } from "@prisma/client"; import { PrismaService } from "../database/prisma.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { Logger } from "nestjs-pino"; import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js"; -export enum AuditAction { - LOGIN_SUCCESS = "LOGIN_SUCCESS", - LOGIN_FAILED = "LOGIN_FAILED", - LOGIN_OTP_SENT = "LOGIN_OTP_SENT", - LOGIN_OTP_VERIFIED = "LOGIN_OTP_VERIFIED", - LOGIN_OTP_FAILED = "LOGIN_OTP_FAILED", - LOGIN_SESSION_INVALIDATED = "LOGIN_SESSION_INVALIDATED", - LOGOUT = "LOGOUT", - SIGNUP = "SIGNUP", - PASSWORD_RESET = "PASSWORD_RESET", - PASSWORD_CHANGE = "PASSWORD_CHANGE", - ACCOUNT_LOCKED = "ACCOUNT_LOCKED", - ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED", - PROFILE_UPDATE = "PROFILE_UPDATE", - MFA_ENABLED = "MFA_ENABLED", - MFA_DISABLED = "MFA_DISABLED", - API_ACCESS = "API_ACCESS", - SYSTEM_MAINTENANCE = "SYSTEM_MAINTENANCE", - // Address reconciliation (Salesforce dual-write) - ADDRESS_UPDATE = "ADDRESS_UPDATE", - ADDRESS_UPDATE_PARTIAL = "ADDRESS_UPDATE_PARTIAL", - ADDRESS_RECONCILE_QUEUED = "ADDRESS_RECONCILE_QUEUED", - ADDRESS_RECONCILE_SUCCESS = "ADDRESS_RECONCILE_SUCCESS", - ADDRESS_RECONCILE_FAILED = "ADDRESS_RECONCILE_FAILED", -} +// Re-export AuditAction from Prisma for consumers +export { AuditAction } from "@prisma/client"; export interface AuditLogData { userId?: string | undefined; diff --git a/apps/bff/src/infra/mappers/mapping.mapper.ts b/apps/bff/src/infra/mappers/mapping.mapper.ts index 894b9e13..d2895699 100644 --- a/apps/bff/src/infra/mappers/mapping.mapper.ts +++ b/apps/bff/src/infra/mappers/mapping.mapper.ts @@ -14,6 +14,9 @@ import type { UserIdMapping } from "@bff/modules/id-mappings/domain/index.js"; * Maps Prisma IdMapping entity to Domain UserIdMapping type */ export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMapping { + if (!mapping.sfAccountId) { + throw new Error(`IdMapping for user ${mapping.userId} is missing sfAccountId`); + } return { id: mapping.userId, userId: mapping.userId, diff --git a/apps/portal/src/components/molecules/DataTable/DataTable.tsx b/apps/portal/src/components/molecules/DataTable/DataTable.tsx index 4256abe7..bfb8940b 100644 --- a/apps/portal/src/components/molecules/DataTable/DataTable.tsx +++ b/apps/portal/src/components/molecules/DataTable/DataTable.tsx @@ -1,11 +1,16 @@ import type { ReactNode } from "react"; import { EmptyState } from "@/components/atoms/empty-state"; +import { ChevronRightIcon } from "@heroicons/react/24/outline"; interface Column { key: string; header: string; render: (item: T) => ReactNode; className?: string; + /** If true, this column will be emphasized in mobile card view (shown first/larger) */ + primary?: boolean; + /** If true, this column will be hidden in mobile card view */ + hideOnMobile?: boolean; } interface DataTableProps { @@ -18,6 +23,8 @@ interface DataTableProps { }; onRowClick?: (item: T) => void; className?: string; + /** Force table view even on mobile (not recommended for UX) */ + forceTableView?: boolean; } export function DataTable({ @@ -26,6 +33,7 @@ export function DataTable({ emptyState, onRowClick, className = "", + forceTableView = false, }: DataTableProps) { if (data.length === 0 && emptyState) { return ( @@ -38,43 +46,120 @@ export function DataTable({ ); } + // Separate primary column for mobile card header + const primaryColumn = columns.find(col => col.primary); + const mobileColumns = columns.filter(col => !col.hideOnMobile && !col.primary); + return ( -
- - - - {columns.map(column => ( - - ))} - - - - {data.map(item => ( - + {/* Mobile Card View - Hidden on md and up */} + {!forceTableView && ( +
+ {data.map((item, index) => ( +
onRowClick?.(item)} + role={onRowClick ? "button" : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={e => { + if (onRowClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onRowClick(item); + } + }} + style={{ + animationDelay: `${index * 50}ms`, + }} > + {/* Primary content row with chevron if clickable */} +
+
+ {primaryColumn ? ( +
+ {primaryColumn.render(item)} +
+ ) : ( +
+ {columns[0]?.render(item)} +
+ )} +
+ {onRowClick && ( + + )} +
+ + {/* Secondary columns as label-value pairs */} +
+ {mobileColumns.map((column, colIndex) => { + // Skip the first column if no primary is defined (already shown above) + if (!primaryColumn && colIndex === 0 && column === columns[0]) return null; + + return ( +
+ + {column.header} + + + {column.render(item)} + +
+ ); + })} +
+
+ ))} +
+ )} + + {/* Desktop Table View - Hidden below md */} +
+
- {column.header} -
+ + {columns.map(column => ( - + {column.header} + ))} - ))} - -
- {column.render(item)} -
-
+ + + {data.map(item => ( + onRowClick?.(item)} + > + {columns.map(column => ( + + {column.render(item)} + + ))} + + ))} + + + + ); } diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index a663c41b..8d7cc2b9 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -40,10 +40,11 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }: return (
-
+
+ {/* Mobile menu button - 44px minimum touch target */}
+ {/* Auth Buttons - Desktop */} {isAuthenticated ? ( @@ -303,11 +301,28 @@ export function PublicShell({ children }: PublicShellProps) {
)} - {/* Mobile Menu Button */} + {/* Auth Button - Mobile (visible in header) */} + {isAuthenticated ? ( + + Account + + ) : ( + + Sign in + + )} + + {/* Mobile Menu Button - 44px touch target */}
- {/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */} + {/* Mobile Menu Overlay - Full screen with slide animation */} {mobileMenuOpen && ( -
-
)}
-
+
{children}
diff --git a/apps/portal/src/features/marketing/views/AboutUsView.tsx b/apps/portal/src/features/marketing/views/AboutUsView.tsx index 8451dcfc..53b57181 100644 --- a/apps/portal/src/features/marketing/views/AboutUsView.tsx +++ b/apps/portal/src/features/marketing/views/AboutUsView.tsx @@ -23,18 +23,6 @@ import { * and mission statement for Assist Solutions. */ export function AboutUsView() { - // Sample company logos for the trusted by carousel - const trustedCompanies = [ - { name: "Company 1", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 2", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 3", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 4", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 5", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 6", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 7", logo: "/assets/images/placeholder-logo.png" }, - { name: "Company 8", logo: "/assets/images/placeholder-logo.png" }, - ]; - const values = [ { text: "Make technology accessible for everyone, regardless of language barriers.", @@ -170,79 +158,8 @@ export function AboutUsView() { - - - {/* Trusted By Section - Infinite Carousel */} -
- {/* Gradient fade to next section */} + {/* Gradient fade to Business Solutions section */}
- -
-

- Trusted by Leading Companies -

-
- - {/* Infinite Carousel */} -
- {/* Gradient masks for fade effect on edges */} -
-
- - {/* Scrolling container */} -
-
- {/* First set of logos */} - {trustedCompanies.map((company, index) => ( -
- {company.name} -
- ))} - {/* Duplicate set for seamless loop */} - {trustedCompanies.map((company, index) => ( -
- {company.name} -
- ))} -
-
-
- - {/* CSS for infinite scroll animation */} -
{/* Business Solutions Carousel */}