diff --git a/.env.example b/.env.example index 1b692c77..62462cdb 100644 --- a/.env.example +++ b/.env.example @@ -44,11 +44,12 @@ PROVIDER_ENCRYPTION_SECRET= # (required) Independent encr # DEPLOYMENT_MODE= # ---- Cognipeer Support --------------------------------------- -# Help & Support entry points stay hidden until SUPPORT_BASE_URL is set. +# On SaaS the Help & Support entry points appear only when all three values are +# set, so a click can never end in a 503. # SUPPORT_HANDOFF_SECRET must be at least 32 characters and must equal CRM's # SUPPORT_HANDOFF_SECRET_CONSOLE. It is server-only — never use NEXT_PUBLIC_. -# On-prem: when SUPPORT_BASE_URL is set but the CRM values are not, Console -# sends the user to the Support login page instead of failing. +# On-prem: SUPPORT_BASE_URL alone is enough; Console then sends the user to the +# Support login page instead of failing. # SUPPORT_BASE_URL=https://support.cognipeer.com # SUPPORT_CRM_API_URL= # SUPPORT_HANDOFF_SECRET= diff --git a/docker-compose.yml b/docker-compose.yml index 939cc6e8..47addca3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,14 @@ services: - JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env.local or environment} - CACHE_PROVIDER=memory - RATE_LIMIT_PROVIDER=memory + # Self-hosted installs are on-prem: with SUPPORT_BASE_URL alone the Help + # action lands on the Support login instead of failing. + - DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-onprem} + # Cognipeer Support. Leave empty to hide every Support entry point. + # SUPPORT_HANDOFF_SECRET must equal CRM's SUPPORT_HANDOFF_SECRET_CONSOLE. + - SUPPORT_BASE_URL=${SUPPORT_BASE_URL:-} + - SUPPORT_CRM_API_URL=${SUPPORT_CRM_API_URL:-} + - SUPPORT_HANDOFF_SECRET=${SUPPORT_HANDOFF_SECRET:-} volumes: - app-data:/app/data restart: unless-stopped diff --git a/src/__tests__/api/support.test.ts b/src/__tests__/api/support.test.ts new file mode 100644 index 00000000..d6b03ffe --- /dev/null +++ b/src/__tests__/api/support.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/services/support/supportHandoff', () => ({ + createSupportHandoff: vi.fn(), + isSupportEntryPointEnabled: vi.fn(), +})); + +import { + createSupportHandoff, + isSupportEntryPointEnabled, +} from '@/lib/services/support/supportHandoff'; +import { supportApiPlugin } from '@/server/api/plugins/support'; +import { createFastifyApiTestApp, parseJsonBody } from '../helpers/fastify-api'; + +const SESSION_HEADERS = { + 'x-tenant-db-name': 'tenant_acme', + 'x-tenant-id': 'tenant-1', + 'x-tenant-slug': 'acme', + 'x-user-email': 'ada@example.com', + 'x-user-id': 'user-1', + 'x-user-role': 'owner', + 'x-license-type': 'FREE', +}; + +const mockedHandoff = vi.mocked(createSupportHandoff); +const mockedEnabled = vi.mocked(isSupportEntryPointEnabled); + +async function app() { + return createFastifyApiTestApp(supportApiPlugin); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockedEnabled.mockReturnValue(true); + mockedHandoff.mockResolvedValue({ ok: true, url: 'https://support.example.com/api/auth/handoff?code=shc_x' }); +}); + +describe('GET /api/support/status', () => { + it('reports whether an entry point should be shown', async () => { + const response = await (await app()).inject({ + method: 'GET', + url: '/api/support/status', + headers: SESSION_HEADERS, + }); + + expect(response.statusCode).toBe(200); + expect(parseJsonBody<{ enabled: boolean }>(response.body).enabled).toBe(true); + }); + + it('rejects an unauthenticated caller', async () => { + const response = await (await app()).inject({ method: 'GET', url: '/api/support/status' }); + expect(response.statusCode).toBe(401); + }); +}); + +describe('POST /api/support/handoff', () => { + it('passes the session identity to the handoff service', async () => { + await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'en' }, + }); + + expect(mockedHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: 'tenant-1', + userId: 'user-1', + userEmail: 'ada@example.com', + locale: 'en', + }), + ); + }); + + it('forwards diagnostics collected by an error surface', async () => { + await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { + locale: 'tr', + summary: 'boom', + diagnostics: { category: 'dashboard_error', page: '/dashboard' }, + }, + }); + + expect(mockedHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + summary: 'boom', + diagnostics: { category: 'dashboard_error', page: '/dashboard' }, + }), + ); + }); + + it('defaults to Turkish when no locale is sent', async () => { + await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: {}, + }); + + expect(mockedHandoff).toHaveBeenCalledWith(expect.objectContaining({ locale: 'tr' })); + }); + + it('rejects an unknown locale instead of guessing', async () => { + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'de' }, + }); + + expect(response.statusCode).toBe(400); + expect(mockedHandoff).not.toHaveBeenCalled(); + }); + + it('rejects diagnostics that are not an object', async () => { + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { diagnostics: ['not', 'an', 'object'] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects an unauthenticated caller', async () => { + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + payload: { locale: 'tr' }, + }); + + expect(response.statusCode).toBe(401); + expect(mockedHandoff).not.toHaveBeenCalled(); + }); + + it('surfaces the service status and keeps the response uncacheable', async () => { + mockedHandoff.mockResolvedValue({ + ok: false, + status: 503, + message: 'Support integration is not configured.', + }); + + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'tr' }, + }); + + expect(response.statusCode).toBe(503); + }); + + it('marks a login fallback so the caller can explain the missing diagnostics', async () => { + mockedHandoff.mockResolvedValue({ + ok: true, + url: 'https://support.example.com/tr/login?diagnostics=unavailable', + diagnosticsUnavailable: true, + }); + + const response = await (await app()).inject({ + method: 'POST', + url: '/api/support/handoff', + headers: SESSION_HEADERS, + payload: { locale: 'tr' }, + }); + + expect(response.headers['cache-control']).toBe('no-store'); + expect(parseJsonBody<{ diagnosticsUnavailable?: boolean }>(response.body).diagnosticsUnavailable) + .toBe(true); + }); +}); diff --git a/src/__tests__/unit/support-handoff.test.ts b/src/__tests__/unit/support-handoff.test.ts new file mode 100644 index 00000000..f6e95486 --- /dev/null +++ b/src/__tests__/unit/support-handoff.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = { + findTenantById: vi.fn(), + switchToTenant: vi.fn().mockResolvedValue(undefined), + assertTenantContext: vi.fn(), + findUserById: vi.fn(), +}; + +vi.mock('@/lib/database', () => ({ + getDatabase: vi.fn(async () => db), +})); + +const config = { + support: { + baseUrl: 'https://support.example.com', + crmApiUrl: 'https://crm.example.com', + handoffSecret: 'a'.repeat(32), + }, + deployment: { mode: 'saas' as 'saas' | 'onprem', isOnPrem: false }, +}; + +vi.mock('@/lib/core/config', () => ({ + getConfig: () => config, +})); + +import { + createSupportHandoff, + isSupportEntryPointEnabled, +} from '@/lib/services/support/supportHandoff'; + +const input = { + tenantId: '65f1c0ffee0000000000abcd', + userId: 'user-1', + userEmail: ' Ada@Example.COM ', + locale: 'tr' as const, +}; + +function crmResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +beforeEach(() => { + vi.clearAllMocks(); + config.support = { + baseUrl: 'https://support.example.com', + crmApiUrl: 'https://crm.example.com', + handoffSecret: 'a'.repeat(32), + }; + config.deployment = { mode: 'saas', isOnPrem: false }; + db.findTenantById.mockResolvedValue({ + _id: input.tenantId, + companyName: 'Acme Inc', + slug: 'acme', + dbName: 'tenant_acme', + }); + db.findUserById.mockResolvedValue({ name: 'Ada Lovelace', email: 'ada@example.com' }); +}); + +describe('createSupportHandoff', () => { + it('anchors support identity to the tenant id, not the renameable slug', async () => { + const fetchMock = vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' })); + vi.stubGlobal('fetch', fetchMock); + + const result = await createSupportHandoff(input); + + expect(result.ok).toBe(true); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.issuer).toBe('console'); + expect(body.externalInstallationId).toBe(input.tenantId); + // A null organizationId makes the CRM derive an issuer-scoped key, so a + // slug rename can never orphan the customer's ticket history. + expect(body.context.organizationId).toBeNull(); + expect(body.context.workspaceName).toBe('Acme Inc'); + }); + + it('sends a display name so support does not address an email address', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' }))); + + await createSupportHandoff(input); + + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.context.userName).toBe('Ada Lovelace'); + expect(body.email).toBe('ada@example.com'); + }); + + it('falls back to the email when the user record cannot be read', async () => { + db.findUserById.mockRejectedValue(new Error('tenant db unavailable')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' }))); + + const result = await createSupportHandoff(input); + + expect(result.ok).toBe(true); + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.context.userName).toBe('ada@example.com'); + }); + + it('returns the Support callback URL carrying the single-use code', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_abc' }))); + + const result = await createSupportHandoff(input); + + expect(result).toMatchObject({ + ok: true, + url: 'https://support.example.com/api/auth/handoff?code=shc_abc&locale=tr', + }); + }); + + it('creates a diagnostic draft first and links it to the handoff', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(crmResponse({ id: 'draft-1' })) + .mockResolvedValueOnce(crmResponse({ code: 'shc_abc' })); + vi.stubGlobal('fetch', fetchMock); + + await createSupportHandoff({ ...input, summary: 'boom', diagnostics: { category: 'x' } }); + + expect(fetchMock.mock.calls[0][0]).toContain('/api/internal-support/v1/diagnostic-drafts'); + const handoffBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(handoffBody.diagnosticDraftId).toBe('draft-1'); + }); + + it('never leaks the CRM reason to the caller', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(crmResponse({ error: 'secret internal reason' }, 403)), + ); + + const result = await createSupportHandoff(input); + + expect(result).toEqual({ ok: false, status: 403, message: 'Support access denied.' }); + }); + + it('sends an on-prem install to the Support login when CRM is unreachable', async () => { + config.deployment = { mode: 'onprem', isOnPrem: true }; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + + const result = await createSupportHandoff(input); + + expect(result).toMatchObject({ + ok: true, + url: 'https://support.example.com/tr/login?diagnostics=unavailable', + diagnosticsUnavailable: true, + }); + }); +}); + +describe('isSupportEntryPointEnabled', () => { + it('hides the entry point when no handoff can be issued on SaaS', () => { + config.support = { baseUrl: 'https://support.example.com', crmApiUrl: '', handoffSecret: '' }; + expect(isSupportEntryPointEnabled()).toBe(false); + }); + + it('keeps it on-prem, where the login fallback still helps', () => { + config.support = { baseUrl: 'https://support.example.com', crmApiUrl: '', handoffSecret: '' }; + config.deployment = { mode: 'onprem', isOnPrem: true }; + expect(isSupportEntryPointEnabled()).toBe(true); + }); + + it('stays hidden without a Support URL', () => { + config.support = { baseUrl: '', crmApiUrl: 'https://crm', handoffSecret: 'a'.repeat(32) }; + config.deployment = { mode: 'onprem', isOnPrem: true }; + expect(isSupportEntryPointEnabled()).toBe(false); + }); +}); diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx index 1b32aecb..8871fc24 100644 --- a/src/app/dashboard/error.tsx +++ b/src/app/dashboard/error.tsx @@ -1,9 +1,47 @@ 'use client'; -import { Button, Center, Stack, Text, ThemeIcon } from '@mantine/core'; -import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import { Button, Center, Group, Stack, Text, ThemeIcon } from '@mantine/core'; +import { IconAlertTriangle, IconLifebuoy, IconRefresh } from '@tabler/icons-react'; +import { isSupportAvailable, openSupport } from '@/lib/support/openSupport'; + +type DashboardErrorProps = { + error: Error & { digest?: string }; + reset: () => void; +}; + +export default function DashboardError({ error, reset }: DashboardErrorProps) { + const [supportEnabled, setSupportEnabled] = useState(false); + const [reporting, setReporting] = useState(false); + + useEffect(() => { + let active = true; + void isSupportAvailable().then((enabled) => { + if (active) setSupportEnabled(enabled); + }); + return () => { + active = false; + }; + }, []); + + const reportToSupport = async () => { + setReporting(true); + try { + await openSupport(document.documentElement.lang === 'en' ? 'en' : 'tr', { + summary: error.message || 'Console dashboard error', + category: 'dashboard_error', + error: { + name: error.name, + message: error.message, + stack: error.stack, + digest: error.digest, + }, + }); + } finally { + setReporting(false); + } + }; -export default function DashboardError({ reset }: { reset: () => void }) { return (
@@ -16,9 +54,21 @@ export default function DashboardError({ reset }: { reset: () => void }) { Retry the request. If it fails again, the route-level API response should be checked. - + + + {supportEnabled ? ( + + ) : null} +
); diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index e837610e..3b3459cf 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import DashboardLayout from '@/components/layout/DashboardLayout'; import { getDatabase } from '@/lib/database'; import { normalizeServicePermissions } from '@/lib/security/rbac'; -import { isSupportReachable } from '@/lib/services/support/supportHandoff'; +import { isSupportEntryPointEnabled } from '@/lib/services/support/supportHandoff'; interface DashboardRouteLayoutProps { children: ReactNode; @@ -54,7 +54,7 @@ export default async function DashboardRouteLayout({ children }: DashboardRouteL return ( { + try { + const db = await getDatabase(); + await db.switchToTenant(tenantDbName); + db.assertTenantContext?.(tenantDbName); + const user = await db.findUserById(input.userId); + return user?.name?.trim() || email; + } catch (error) { + logger.warn('Support handoff could not resolve the user name', { + error: error instanceof Error ? error.message : String(error), + }); + return email; + } +} + export async function createSupportHandoff( input: SupportHandoffInput, ): Promise { @@ -114,6 +148,8 @@ export async function createSupportHandoff( return { ok: false, status: 403, message: 'Support access denied.' }; } + const userName = await resolveUserName(input, tenant.dbName, email); + try { let diagnosticDraftId: string | undefined; if (input.diagnostics) { @@ -134,9 +170,12 @@ export async function createSupportHandoff( diagnosticDraftId, context: { workspaceName: tenant.companyName, - organizationId: tenant.slug || null, + // A Console tenant is both the customer and the workspace, so the CRM + // derives an issuer-scoped organization key from the tenant id. Sending + // the slug here would anchor support history to a renameable value. + organizationId: null, userId: input.userId, - userName: email, + userName, }, }); diff --git a/src/lib/support/openSupport.ts b/src/lib/support/openSupport.ts index 1e0623a8..1c1c029d 100644 --- a/src/lib/support/openSupport.ts +++ b/src/lib/support/openSupport.ts @@ -4,11 +4,33 @@ import { apiRequest } from '@/lib/api/client'; type HandoffResponse = { url: string; diagnosticsUnavailable?: boolean }; +export type SupportDiagnostic = { + summary: string; + category?: string; + error?: { name?: string; message?: string; stack?: string; digest?: string }; + requestId?: string; +}; + +/** Everything the CRM needs to reproduce the problem; it redacts on its side. */ +function buildDiagnostics(diagnostic: SupportDiagnostic) { + return { + category: diagnostic.category ?? 'application_error', + error: diagnostic.error, + requestId: diagnostic.requestId, + page: window.location.pathname, + environment: process.env.NEXT_PUBLIC_ENV || process.env.NODE_ENV, + occurredAt: new Date().toISOString(), + }; +} + /** * Opens Support in a new tab. The tab is opened before the request so the * browser attributes it to the click and does not block it as a popup. */ -export async function openSupport(locale: 'tr' | 'en' = 'tr'): Promise { +export async function openSupport( + locale: 'tr' | 'en' = 'tr', + diagnostic?: SupportDiagnostic, +): Promise { const supportWindow = window.open('about:blank', '_blank'); if (!supportWindow) return false; supportWindow.opener = null; @@ -16,7 +38,15 @@ export async function openSupport(locale: 'tr' | 'en' = 'tr'): Promise try { const response = await apiRequest('/api/support/handoff', { method: 'POST', - body: JSON.stringify({ locale }), + body: JSON.stringify({ + locale, + ...(diagnostic + ? { + summary: diagnostic.summary.slice(0, 500), + diagnostics: buildDiagnostics(diagnostic), + } + : {}), + }), }); supportWindow.location.replace(response.url); return true; @@ -26,3 +56,13 @@ export async function openSupport(locale: 'tr' | 'en' = 'tr'): Promise return false; } } + +/** Lets a client surface hide its Support action when no handoff can be issued. */ +export async function isSupportAvailable(): Promise { + try { + const response = await apiRequest<{ enabled: boolean }>('/api/support/status'); + return response.enabled === true; + } catch { + return false; + } +} diff --git a/src/server/api/plugins/support.ts b/src/server/api/plugins/support.ts index 90d0fed4..7891af55 100644 --- a/src/server/api/plugins/support.ts +++ b/src/server/api/plugins/support.ts @@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { createLogger } from '@/lib/core/logger'; import { createSupportHandoff, - isSupportConfigured, + isSupportEntryPointEnabled, type SupportLocale, } from '@/lib/services/support/supportHandoff'; import { readJsonBody, requireSessionContext, withApiRequestContext } from '../fastify-utils'; @@ -23,7 +23,7 @@ export const supportApiPlugin: FastifyPluginAsync = async (app) => { app.get('/support/status', withApiRequestContext(async (request, reply) => { try { requireSessionContext(request); - return reply.code(200).send({ enabled: isSupportConfigured() }); + return reply.code(200).send({ enabled: isSupportEntryPointEnabled() }); } catch { return reply.code(401).send({ error: 'Unauthorized' }); }