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}}