diff --git a/.claude/agents/triage.md b/.claude/agents/triage.md index 8cf678fa9f..9a6fb05824 100644 --- a/.claude/agents/triage.md +++ b/.claude/agents/triage.md @@ -160,9 +160,18 @@ For actionable issues, produce a plan an engineer can execute immediately: - [ ] Visual verification: [if UI, what to check in browser] ``` -## Step 7 — Deliver Results +## Step 7 — Present Findings & Get Direction -**If triaging a GitHub issue**, post findings directly: +**Do not jump straight to action.** Present your findings first and ask the user what they'd like to do next. The goal is to make sure we do the right thing based on the user's judgment. + +**If triaging a GitHub issue:** + +1. Present your findings to the user (classification, severity, impact, root cause, implementation plan) +2. Thank the reporter for filing the issue +3. Ask the user to review your findings and choose next steps before posting anything to GitHub +4. Only post the triage comment to GitHub after the user confirms the direction + +When posting (after user approval): ```bash gh issue comment --body "$(cat <<'EOF' @@ -181,6 +190,9 @@ gh issue comment --body "$(cat <<'EOF' ### Related - [Links to related issues, similar patterns found elsewhere] + +--- +Thank you for reporting this issue! If you have any additional information, reproduction steps, or context that could help, please don't hesitate to share — it's always valuable. EOF )" @@ -213,11 +225,16 @@ After posting the triage comment: # Final Ask (Required) -Before ending triage, always call `vscode_askQuestions` (askuserquestion) and ask whether they want: +Before ending triage, always call `vscode_askQuestions` (askuserquestion) with the following: -- Deeper analysis on any specific area -- To hand off to `@engineer` immediately -- To adjust severity or priority -- Any other follow-up +1. **Thank the user** for reporting/raising the issue +2. **Present your recommended next steps** as options and ask which direction to go: + - Deeper analysis on any specific area + - Hand off to `@engineer` to implement the proposed plan + - Adjust severity or priority + - Request more information from the reporter + - Any other follow-up +3. **Ask if they have additional context** — "Do you have any additional information or context that might help with this issue?" +4. **Ask what to triage next** — "Is there another issue you'd like me to triage?" -Do not end with findings alone — confirm next action. +Do not end with findings alone — always confirm next action and prompt for the next issue. diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 80b2b975f7..3e5e39f240 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -29,6 +29,7 @@ "pretty-ms": "^9.3.0", "runed": "^0.37.1", "shiki": "^4.0.2", + "svelte-intercom": "^0.0.35", "svelte-sonner": "^1.1.0", "svelte-time": "^2.1.0", "tailwind-merge": "^3.5.0", @@ -1215,6 +1216,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@intercom/messenger-js-sdk": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@intercom/messenger-js-sdk/-/messenger-js-sdk-0.0.14.tgz", + "integrity": "sha512-2dH4BDAh9EI90K7hUkAdZ76W79LM45Sd1OBX7t6Vzy8twpNiQ5X+7sH9G5hlJlkSGnf+vFWlFcy9TOYAyEs1hA==", + "license": "MIT" + }, "node_modules/@internationalized/date": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", @@ -7825,6 +7832,17 @@ } } }, + "node_modules/svelte-intercom": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/svelte-intercom/-/svelte-intercom-0.0.35.tgz", + "integrity": "sha512-iJ1CYQW110titaAD6EjZqOl5LWRJQR9T2Np4b4NkT1y560w9d814mlBEH0djGo5IfDaWH3vT12eGIPK0nxxP3Q==", + "dependencies": { + "@intercom/messenger-js-sdk": "^0.0.14" + }, + "peerDependencies": { + "svelte": ">=5.0.0" + } + }, "node_modules/svelte-sonner": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.0.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 1d2d918657..fea0936bcf 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -88,6 +88,7 @@ "pretty-ms": "^9.3.0", "runed": "^0.37.1", "shiki": "^4.0.2", + "svelte-intercom": "^0.0.35", "svelte-sonner": "^1.1.0", "svelte-time": "^2.1.0", "tailwind-merge": "^3.5.0", @@ -100,4 +101,4 @@ "overrides": { "storybook": "$storybook" } -} \ No newline at end of file +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts index 61f6299027..666b82d4ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts @@ -1,9 +1,16 @@ -import { useFetchClient } from '@exceptionless/fetchclient'; +import { env } from '$env/dynamic/public'; +import { getIntercomTokenSessionKey, intercomTokenRefreshIntervalMs } from '$features/intercom/config'; +import { ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createQuery } from '@tanstack/svelte-query'; import type { Login, TokenResult } from './models'; import { accessToken } from './index.svelte'; +const queryKeys = { + intercom: (accessToken: null | string) => ['Auth', 'intercom', getIntercomTokenSessionKey(accessToken)] as const +}; + export async function cancelResetPassword(token: string) { const client = useFetchClient(); const response = await client.post(`auth/cancel-reset-password/${token}`, { @@ -38,6 +45,23 @@ export async function forgotPassword(email: string) { return await client.get(`auth/forgot-password/${email}`); } +export function getIntercomTokenQuery() { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!env.PUBLIC_INTERCOM_APPID, + queryFn: async ({ signal }) => { + const client = useFetchClient(); + const response = await client.getJSON('auth/intercom', { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.intercom(accessToken.current), + refetchInterval: intercomTokenRefreshIntervalMs, + staleTime: intercomTokenRefreshIntervalMs + })); +} + /** * Checks if an email address is already in use. * @param email The email address to check @@ -72,7 +96,6 @@ export async function login(email: string, password: string) { export async function logout() { const client = useFetchClient(); await client.get('auth/logout', { expectedStatusCodes: [200, 401] }); - accessToken.current = ''; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts index 05f2de1c76..224e75b666 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts @@ -12,6 +12,7 @@ export { cancelResetPassword, changePassword, forgotPassword, + getIntercomTokenQuery, isEmailAddressTaken, login, logout, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/chat.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/chat.test.ts new file mode 100644 index 0000000000..b9572b32e0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/chat.test.ts @@ -0,0 +1,26 @@ +import { supportIssuesHref } from '$features/shared/help-links'; +import { describe, expect, it, vi } from 'vitest'; + +import { openSupportChat } from './chat'; + +describe('openSupportChat', () => { + it('opens Intercom messages when the messenger is available', () => { + const intercom = { + showMessages: vi.fn() + }; + const openWindow = vi.fn(); + + openSupportChat(intercom, openWindow); + + expect(intercom.showMessages).toHaveBeenCalledOnce(); + expect(openWindow).not.toHaveBeenCalled(); + }); + + it('falls back to the support issues page when the messenger is unavailable', () => { + const openWindow = vi.fn(); + + openSupportChat(undefined, openWindow); + + expect(openWindow).toHaveBeenCalledWith(supportIssuesHref, '_blank', 'noopener,noreferrer'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/chat.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/chat.ts new file mode 100644 index 0000000000..defd8d805b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/chat.ts @@ -0,0 +1,14 @@ +import { supportIssuesHref } from '$features/shared/help-links'; + +export interface IntercomMessenger { + showMessages: () => void; +} + +export function openSupportChat(intercom: IntercomMessenger | null | undefined, openWindow: typeof globalThis.open = globalThis.open) { + if (intercom) { + intercom.showMessages(); + return; + } + + openWindow?.(supportIssuesHref, '_blank', 'noopener,noreferrer'); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/config.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/config.test.ts new file mode 100644 index 0000000000..2ce0cee0fa --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { + getIntercomTokenSessionKey, + intercomJwtLifetimeMs, + intercomTokenRefreshIntervalMs, + intercomTokenRefreshLeadTimeMs, + shouldLoadIntercomOrganization +} from './config'; + +describe('intercom token refresh cadence', () => { + it('refreshes five minutes before a one-hour token expires', () => { + expect(intercomJwtLifetimeMs).toBe(60 * 60 * 1000); + expect(intercomTokenRefreshLeadTimeMs).toBe(5 * 60 * 1000); + expect(intercomTokenRefreshIntervalMs).toBe(55 * 60 * 1000); + expect(intercomTokenRefreshIntervalMs).toBe(intercomJwtLifetimeMs - intercomTokenRefreshLeadTimeMs); + }); + + it('uses a stable non-secret session key for the current auth token', () => { + expect(getIntercomTokenSessionKey(null)).toBeNull(); + expect(getIntercomTokenSessionKey('token-a')).toBe(getIntercomTokenSessionKey('token-a')); + expect(getIntercomTokenSessionKey('token-a')).not.toBe('token-a'); + expect(getIntercomTokenSessionKey('token-a')).not.toBe(getIntercomTokenSessionKey('token-b')); + }); + + it('only loads organization details after Intercom is active for the session', () => { + expect(shouldLoadIntercomOrganization(undefined, true)).toBe(false); + expect(shouldLoadIntercomOrganization('app_123', false)).toBe(false); + expect(shouldLoadIntercomOrganization('app_123', true)).toBe(true); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/config.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/config.ts new file mode 100644 index 0000000000..41d4eafb03 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/config.ts @@ -0,0 +1,20 @@ +export const intercomJwtLifetimeMs = 60 * 60 * 1000; +export const intercomTokenRefreshLeadTimeMs = 5 * 60 * 1000; +export const intercomTokenRefreshIntervalMs = intercomJwtLifetimeMs - intercomTokenRefreshLeadTimeMs; + +export function getIntercomTokenSessionKey(accessToken: null | string) { + if (!accessToken) { + return null; + } + + let hash = 0; + for (const character of accessToken) { + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + } + + return hash.toString(36); +} + +export function shouldLoadIntercomOrganization(intercomAppId: null | string | undefined, isIntercomTokenReady: boolean) { + return !!intercomAppId && isIntercomTokenReady; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.test.ts new file mode 100644 index 0000000000..431682a689 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.test.ts @@ -0,0 +1,55 @@ +import type { ViewCurrentUser, ViewOrganization } from '$lib/generated/api'; + +import { describe, expect, it } from 'vitest'; + +import { buildIntercomBootOptions } from './context.svelte'; + +describe('buildIntercomBootOptions', () => { + it('returns undefined when the intercom token is missing', () => { + // Arrange + const user = { id: '67620d1818f5a40d98f3e812' } as ViewCurrentUser; + + // Act + const result = buildIntercomBootOptions(user, undefined, undefined); + + // Assert + expect(result).toBeUndefined(); + }); + + it('builds boot options from the current user organization and token', () => { + // Arrange + const organizationCreatedUtc = '2024-12-21T01:23:45.000Z'; + const user = { + email_address: 'test-user@example.com', + full_name: 'Test User', + id: '67620d1818f5a40d98f3e812' + } as ViewCurrentUser; + const organization = { + billing_price: 29, + created_utc: organizationCreatedUtc, + id: '67620d1818f5a40d98f3e999', + name: 'Acme Corp', + plan_id: 'unlimited' + } as ViewOrganization; + + // Act + const result = buildIntercomBootOptions(user, organization, 'signed-intercom-token'); + + // Assert + expect(result).toEqual({ + company: { + createdAt: String(Math.floor(Date.parse(organizationCreatedUtc) / 1000)), + id: organization.id, + monthlySpend: 29, + name: 'Acme Corp', + plan: 'unlimited' + }, + createdAt: String(parseInt('67620d18', 16)), + email: 'test-user@example.com', + hideDefaultLauncher: true, + intercomUserJwt: 'signed-intercom-token', + name: 'Test User', + userId: user.id + }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.ts new file mode 100644 index 0000000000..6c4d29bd6d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.ts @@ -0,0 +1,60 @@ +import type { ViewCurrentUser, ViewOrganization } from '$lib/generated/api'; + +import { getContext } from 'svelte'; + +import type { IntercomContext } from './intercom-initializer.svelte'; + +import { INTERCOM_CONTEXT_KEY } from './keys'; + +/** + * Builds Intercom boot options from user and organization data. + * Uses the auth feature's current Intercom token for authentication. + * Uses the camelCase shape expected by the Svelte SDK, which converts keys to Intercom's payload format. + */ +export function buildIntercomBootOptions(user: undefined | ViewCurrentUser, organization: undefined | ViewOrganization, token?: string) { + if (!user || !token) { + return undefined; + } + + const userCreatedAt = user.id ? parseInt(user.id.substring(0, 8), 16).toString() : undefined; + const organizationCreatedAt = getUnixTimestampSeconds(organization?.created_utc); + + return { + company: organization + ? { + createdAt: organizationCreatedAt?.toString(), + id: organization.id, + monthlySpend: organization.billing_price, + name: organization.name, + plan: organization.plan_id + } + : undefined, + createdAt: userCreatedAt?.toString(), + email: user.email_address, + hideDefaultLauncher: true, + intercomUserJwt: token, + name: user.full_name, + userId: user.id + }; +} + +/** + * Get the Intercom context from the nearest IntercomInitializer. + * Must be called inside a component wrapped by IntercomInitializer. + */ +export function getIntercom(): IntercomContext | undefined { + return getContext(INTERCOM_CONTEXT_KEY); +} + +function getUnixTimestampSeconds(dateTime?: string): string | undefined { + if (!dateTime) { + return undefined; + } + + const milliseconds = Date.parse(dateTime); + if (Number.isNaN(milliseconds)) { + return undefined; + } + + return Math.floor(milliseconds / 1000).toString(); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/index.ts new file mode 100644 index 0000000000..1104fc7342 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/index.ts @@ -0,0 +1,3 @@ +export { buildIntercomBootOptions, getIntercom } from './context.svelte'; +export { default as IntercomInitializer } from './intercom-initializer.svelte'; +export { INTERCOM_CONTEXT_KEY } from './keys'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-initializer.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-initializer.svelte new file mode 100644 index 0000000000..036b98951e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-initializer.svelte @@ -0,0 +1,75 @@ + + + + +{@render children()} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/keys.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/keys.ts new file mode 100644 index 0000000000..854707ec1e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/keys.ts @@ -0,0 +1 @@ +export const INTERCOM_CONTEXT_KEY = Symbol('intercom'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/help-links.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/help-links.ts new file mode 100644 index 0000000000..4045189948 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/help-links.ts @@ -0,0 +1,4 @@ +export const apiReferenceHref = '/docs/index.html'; +export const documentationHref = 'https://exceptionless.com/docs/'; +export const githubRepositoryHref = 'https://github.com/exceptionless/Exceptionless'; +export const supportIssuesHref = `${githubRepositoryHref}/issues`; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index df8b0b9dba..92f5697c62 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -7,12 +7,14 @@ import { resolve } from '$app/paths'; import { A } from '$comp/typography'; import * as Avatar from '$comp/ui/avatar/index'; + import { Badge } from '$comp/ui/badge'; import * as DropdownMenu from '$comp/ui/dropdown-menu/index'; import * as Sidebar from '$comp/ui/sidebar/index'; import { useSidebar } from '$comp/ui/sidebar/index'; import { Skeleton } from '$comp/ui/skeleton'; import ImpersonateOrganizationDialog from '$features/organizations/components/dialogs/impersonate-organization-dialog.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { apiReferenceHref, documentationHref, githubRepositoryHref, supportIssuesHref } from '$features/shared/help-links'; import GlobalUser from '$features/users/components/global-user.svelte'; import BadgeCheck from '@lucide/svelte/icons/badge-check'; import Bell from '@lucide/svelte/icons/bell'; @@ -35,22 +37,34 @@ interface Props { gravatar: Gravatar; + intercomUnreadCount: number; + isChatEnabled: boolean; isImpersonating?: boolean; isLoading: boolean; + openChat: () => void; organizations?: ViewOrganization[]; user: undefined | ViewCurrentUser; } - let { gravatar, isImpersonating = false, isLoading, organizations = [], user }: Props = $props(); + let { gravatar, intercomUnreadCount = 0, isChatEnabled, isImpersonating = false, isLoading, openChat, organizations = [], user }: Props = $props(); const sidebar = useSidebar(); let openImpersonateDialog = $state(false); + function getUnreadCountLabel(unreadCount: number): string { + return unreadCount > 99 ? '99+' : unreadCount.toString(); + } + function onMenuClick() { if (sidebar.isMobile) { sidebar.toggle(); } } + function onChatClick() { + onMenuClick(); + openChat(); + } + async function impersonateOrganization(vo: ViewOrganization): Promise { await goto(resolve('/(app)')); organization.current = vo.id; @@ -75,6 +89,25 @@ {:else} + {#if isChatEnabled} + + + + + + + {/if} @@ -173,24 +206,33 @@ Help + {#if isChatEnabled} + + + Support + {#if intercomUnreadCount > 0} + {getUnreadCountLabel(intercomUnreadCount)} + {/if} + + {/if} - Documentation + Documentation ⌘gw - Support + Support ⌘gs - GitHub + GitHub ⌘gg - API Reference + API Reference diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index eb7e0da037..6f87a12d31 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -6,8 +6,11 @@ import { page } from '$app/state'; import { useSidebar } from '$comp/ui/sidebar'; import { env } from '$env/dynamic/public'; - import { accessToken, gotoLogin } from '$features/auth/index.svelte'; + import { accessToken, getIntercomTokenQuery, gotoLogin } from '$features/auth/index.svelte'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; + import { buildIntercomBootOptions, getIntercom, IntercomInitializer } from '$features/intercom'; + import { openSupportChat } from '$features/intercom/chat'; + import { shouldLoadIntercomOrganization } from '$features/intercom/config'; import { getOrganizationQuery, getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; import OrganizationNotifications from '$features/organizations/components/organization-notifications.svelte'; import { organization, showOrganizationNotifications } from '$features/organizations/context.svelte'; @@ -21,6 +24,7 @@ import { WebSocketClient } from '$features/websockets/web-socket-client.svelte'; import { useMiddleware } from '@exceptionless/fetchclient'; import { useQueryClient } from '@tanstack/svelte-query'; + import { IntercomProvider } from 'svelte-intercom'; import { fade } from 'svelte/transition'; import { type NavigationItemContext, routes } from '../routes.svelte'; @@ -183,6 +187,20 @@ }); const impersonatedOrganization = $derived(impersonatingOrganizationId ? impersonatedOrganizationQuery.data : undefined); + const intercomAppId = $derived(env.PUBLIC_INTERCOM_APPID ?? ''); + const intercomTokenQuery = getIntercomTokenQuery(); + const shouldFetchIntercomOrganization = $derived(shouldLoadIntercomOrganization(intercomAppId, intercomTokenQuery.isSuccess)); + + // Query for current organization details (for Intercom company data) + const currentOrganizationQuery = getOrganizationQuery({ + route: { + get id() { + return shouldFetchIntercomOrganization ? organization.current : undefined; + } + } + }); + const currentOrganization = $derived(shouldFetchIntercomOrganization ? currentOrganizationQuery.data : undefined); + // Simple organization selection - pick first available if none selected $effect(() => { if (!organizationsQuery.isSuccess) { @@ -214,13 +232,24 @@ return routes().filter((route) => (route.show ? route.show(context) : true)); }); - const isChatEnabled = $derived(!!env.PUBLIC_INTERCOM_APPID); - function openChat() { - // TODO: Implement chat opening logic + // Intercom configuration + const intercomToken = $derived(intercomAppId ? intercomTokenQuery.data?.token : undefined); + const intercomBootOptions = $derived(buildIntercomBootOptions(meQuery.data, currentOrganization, intercomToken)); + let intercomUnreadCount = $state(0); + const isChatEnabled = $derived(!!intercomAppId && !!intercomBootOptions); + const shouldBootIntercom = $derived(!!intercomAppId && !!intercomBootOptions); + + function onIntercomUnreadCountChange(unreadCount: number) { + intercomUnreadCount = Math.max(0, unreadCount); + } + + // Fallback openChat for when Intercom is not enabled + function openChatFallback() { + openSupportChat(undefined); } -{#if isAuthenticated} +{#snippet appShell(openChat: () => void)} {#snippet header()} @@ -234,7 +263,16 @@ {/snippet} {#snippet footer()} - + {/snippet}
@@ -254,4 +292,27 @@
+{/snippet} + +{#if isAuthenticated} + {#if isChatEnabled} + + {@render intercomShell()} + + {:else} + {@render appShell(openChatFallback)} + {/if} {/if} + +{#snippet intercomShell()} + + {@const intercom = getIntercom()} + {@const openChat = () => openSupportChat(intercom)} + {@render appShell(openChat)} + +{/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte index 2fd5a2ad59..1c3e69d641 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte @@ -84,7 +84,7 @@ {#if organizationQuery.isLoading}
- +
{:else if organizationQuery.error} @@ -117,7 +117,7 @@

- + {#snippet tooltip()} - formatDateLabel(v as Date)} /> + formatDateLabel(v as Date)} /> {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index 8c3dba7ffe..5e1d6dd1d9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -8,6 +8,8 @@ import * as Select from '$comp/ui/select'; import { Separator } from '$comp/ui/separator'; import { env } from '$env/dynamic/public'; + import { getIntercom } from '$features/intercom'; + import { openSupportChat } from '$features/intercom/chat'; import { getProjectDefaultTokenQuery, patchToken } from '$features/tokens/api.svelte'; import EnableTokenDialog from '$features/tokens/components/dialogs/enable-token-dialog.svelte'; import { queryParamsState } from 'kit-query-params'; @@ -222,8 +224,11 @@ public partial class App : Application { } }); + // Use Intercom from parent provider context + const intercom = getIntercom(); + function openChat() { - // TODO: Implement chat opening logic + openSupportChat(intercom); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte index 97b665b870..49f21d3ea3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/usage/+page.svelte @@ -112,7 +112,7 @@ {#if projectQuery.isLoading || organizationQuery.isLoading}
- +
{:else if projectQuery.error || organizationQuery.error} @@ -145,7 +145,7 @@

- + {#snippet tooltip()} - formatDateLabel(v as Date)} /> + formatDateLabel(v as Date)} /> {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index 30bb166562..02ada0a385 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -1,4 +1,5 @@ import { resolve } from '$app/paths'; +import { apiReferenceHref, documentationHref, githubRepositoryHref, supportIssuesHref } from '$features/shared/help-links'; import Documentation from '@lucide/svelte/icons/book-open'; import ApiDocumentations from '@lucide/svelte/icons/braces'; import Issues from '@lucide/svelte/icons/bug'; @@ -37,28 +38,28 @@ export function routes(): NavigationItem[] { }, { group: 'Help', - href: 'https://exceptionless.com/docs/', + href: documentationHref, icon: Documentation, openInNewTab: true, title: 'Documentation' }, { group: 'Help', - href: 'https://github.com/exceptionless/Exceptionless/issues', + href: supportIssuesHref, icon: Support, openInNewTab: true, title: 'Support' }, { group: 'Help', - href: 'https://github.com/exceptionless/Exceptionless', + href: githubRepositoryHref, icon: GitHub, openInNewTab: true, title: 'GitHub' }, { group: 'Help', - href: '/docs/index.html', + href: apiReferenceHref, icon: ApiDocumentations, openInNewTab: true, title: 'API' diff --git a/src/Exceptionless.Web/Controllers/AuthController.cs b/src/Exceptionless.Web/Controllers/AuthController.cs index 2879d4935e..52e5441b4d 100644 --- a/src/Exceptionless.Web/Controllers/AuthController.cs +++ b/src/Exceptionless.Web/Controllers/AuthController.cs @@ -1,4 +1,6 @@ using System.Configuration; +using System.IdentityModel.Tokens.Jwt; +using System.Text; using Exceptionless.Core.Authentication; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; @@ -14,6 +16,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.IdentityModel.Tokens; using OAuth2.Client; using OAuth2.Client.Impl; using OAuth2.Configuration; @@ -27,6 +30,7 @@ namespace Exceptionless.Web.Controllers; public class AuthController : ExceptionlessApiController { private readonly AuthOptions _authOptions; + private readonly IntercomOptions _intercomOptions; private readonly IDomainLoginProvider _domainLoginProvider; private readonly IOrganizationRepository _organizationRepository; private readonly IUserRepository _userRepository; @@ -36,12 +40,14 @@ public class AuthController : ExceptionlessApiController private readonly ILogger _logger; private static bool _isFirstUserChecked; + private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); - public AuthController(AuthOptions authOptions, IOrganizationRepository organizationRepository, IUserRepository userRepository, + public AuthController(AuthOptions authOptions, IntercomOptions intercomOptions, IOrganizationRepository organizationRepository, IUserRepository userRepository, ITokenRepository tokenRepository, ICacheClient cacheClient, IMailer mailer, IDomainLoginProvider domainLoginProvider, TimeProvider timeProvider, ILogger logger) : base(timeProvider) { _authOptions = authOptions; + _intercomOptions = intercomOptions; _domainLoginProvider = domainLoginProvider; _organizationRepository = organizationRepository; _userRepository = userRepository; @@ -152,6 +158,41 @@ public async Task> LoginAsync(Login model) return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) }); } + /// + /// Get the current user's Intercom messenger token. + /// + /// Intercom messenger token + /// Intercom is not configured + [HttpGet("intercom")] + public Task> GetIntercomTokenAsync() + { + if (!_intercomOptions.EnableIntercom || String.IsNullOrWhiteSpace(_intercomOptions.IntercomSecret)) + { + ModelState.AddModelError("intercom", "Intercom is not enabled."); + return Task.FromResult>(ValidationProblem(ModelState)); + } + + var issuedAt = _timeProvider.GetUtcNow(); + var expiresAt = issuedAt.Add(IntercomJwtLifetime); + + var signingCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_intercomOptions.IntercomSecret!)), + SecurityAlgorithms.HmacSha256 + ); + + var token = new JwtSecurityToken( + header: new JwtHeader(signingCredentials), + payload: new JwtPayload + { + [JwtRegisteredClaimNames.Exp] = expiresAt.ToUnixTimeSeconds(), + [JwtRegisteredClaimNames.Iat] = issuedAt.ToUnixTimeSeconds(), + ["user_id"] = CurrentUser.Id, + } + ); + + return Task.FromResult>(Ok(new TokenResult { Token = new JwtSecurityTokenHandler().WriteToken(token) })); + } + /// /// Logout the current user and remove the current access token /// diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 68da397f09..54501a789d 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs b/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs index a00ba54c33..b7ec20da12 100644 --- a/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs @@ -14,6 +14,7 @@ using Foundatio.Queues; using Foundatio.Repositories; using Microsoft.AspNetCore.Mvc; +using System.IdentityModel.Tokens.Jwt; using Xunit; using User = Exceptionless.Core.Models.User; @@ -22,6 +23,7 @@ namespace Exceptionless.Tests.Controllers; public class AuthControllerTests : IntegrationTestsBase { private readonly AuthOptions _authOptions; + private readonly IntercomOptions _intercomOptions; private readonly IUserRepository _userRepository; private readonly IOrganizationRepository _organizationRepository; private readonly ITokenRepository _tokenRepository; @@ -31,6 +33,7 @@ public AuthControllerTests(ITestOutputHelper output, AppWebHostFactory factory) _authOptions = GetService(); _authOptions.EnableAccountCreation = true; _authOptions.EnableActiveDirectoryAuth = false; + _intercomOptions = GetService(); _organizationRepository = GetService(); _userRepository = GetService(); @@ -1024,6 +1027,76 @@ await SendRequestAsync(r => r Assert.False(token.IsSuspended); } + + [Fact] + public async Task GetIntercomToken_WithValidAuthenticatedUser_ReturnsJwtAsync() + { + // Arrange + _intercomOptions.IntercomSecret = "test-intercom-secret-with-adequate-length-12345"; + const string email = "intercom-token@exceptionless.io"; + const string password = "Test password"; + const string salt = "1234567890123456"; + var issuedAt = new DateTimeOffset(2026, 3, 19, 12, 0, 0, TimeSpan.Zero); + + TimeProvider.SetUtcNow(issuedAt); + + var user = new User + { + EmailAddress = email, + FullName = "Intercom User", + Password = password.ToSaltedHash(salt), + Roles = AuthorizationRoles.AllScopes, + Salt = salt + }; + + user.MarkEmailAddressVerified(); + await _userRepository.AddAsync(user, o => o.ImmediateConsistency()); + + var authToken = await SendRequestAsAsync(r => r + .Post() + .AppendPath("auth/login") + .Content(new Login + { + Email = email, + Password = password + }) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(authToken); + + // Act + var intercomToken = await SendRequestAsAsync(r => r + .BearerToken(authToken.Token) + .AppendPath("auth/intercom") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(intercomToken); + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(intercomToken.Token); + Assert.Equal(user.Id, jwt.Payload["user_id"]); + Assert.Equal(issuedAt.UtcDateTime, jwt.Payload.IssuedAt); + Assert.Equal(issuedAt.AddHours(1).ToUnixTimeSeconds(), jwt.Payload.Expiration); + } + + [Fact] + public async Task GetIntercomToken_WhenIntercomIsDisabled_ReturnsUnprocessableEntityAsync() + { + // Arrange + _intercomOptions.IntercomSecret = null; + + // Act + var problemDetails = await SendRequestAsAsync(r => r + .BearerToken(TestConstants.UserApiKey) + .AppendPath("auth/intercom") + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + Assert.NotNull(problemDetails); + Assert.Contains(problemDetails.Errors, error => String.Equals(error.Key, "intercom", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task CanLogoutClientAccessTokenAsync() { diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 5756ec39bf..c086708895 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -749,6 +749,29 @@ } } }, + "/api/v2/auth/intercom": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Get the current user's Intercom messenger token.", + "responses": { + "200": { + "description": "Intercom messenger token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResult" + } + } + } + }, + "422": { + "description": "Intercom is not configured" + } + } + } + }, "/api/v2/auth/logout": { "get": { "tags": [ diff --git a/tests/http/auth.http b/tests/http/auth.http index 2c623ab103..372d0117ea 100644 --- a/tests/http/auth.http +++ b/tests/http/auth.http @@ -1,24 +1,29 @@ @apiUrl = http://localhost:5200/api/v2 @email = test@localhost -@password = tes +@password = tester -### Login +### login to test account +# @name login POST {{apiUrl}}/auth/login Content-Type: application/json { - "email": "{{email}}", - "password": "{{password}}" + "email": "{{email}}", + "password": "{{password}}" } +### + +@token = {{login.response.body.$.token}} + ### Signup POST {{apiUrl}}/auth/signup Content-Type: application/json { - "email": "{{email}}", - "password": "{{password}}", - "name": "test" + "email": "{{email}}", + "password": "{{password}}", + "name": "test" } ### Signup with token @@ -26,9 +31,9 @@ POST {{apiUrl}}/auth/signup Content-Type: application/json { - "email": "{{email}}", - "password": "{{password}}", - "name": "test" + "email": "{{email}}", + "password": "{{password}}", + "name": "test" } ### OAuth Login @@ -40,3 +45,7 @@ Content-Type: application/json "clientId": "clientId", "redirectUri": "http://localhost" } + +### Intercom token +GET {{apiUrl}}/auth/intercom +Authorization: Bearer {{token}}