Skip to content

Commit bd7e4fe

Browse files
authored
feat(next): integrate Intercom provider lifecycle in app layout (#2162)
* feat(intercom): add svelte-intercom provider, hidden launcher, reactive boot options, and logout shutdown * fix(intercom): consolidate update calls and fix context scoping in snippet - Consolidate redundant intercom.update() calls into single effect tracking route and visibility - Extract getIntercom() into separate snippet to avoid context scoping issues - Both changes prevent duplicate updates and ensure context is properly available * Keep Intercom support flows on the message list Root cause: the sidebar support action was opening a fresh composer instead of the existing message history, and the new auth test needed immediate consistency so the seeded user was visible before login. The current staged set also carries the Intercom boot/auth wiring that this branch has been validating. * triage template * feat(intercom): implement token refresh and support link fallback - Adds JWT expiration (1 hour) and issued-at claims to the Intercom token on the backend. - Configures the frontend query to automatically refresh the token every 55 minutes to ensure continuous authentication. - Centralizes help and documentation links and provides a fallback to GitHub issues when the Intercom messenger is unavailable or disabled. * Tighten Intercom token refresh and contracts Root cause: the Intercom token query reused a static cache key across auth-session changes, and the new auth/intercom contract updates were missing their HTTP and OpenAPI baselines. * Minimize Intercom lockfile changes Root cause: the initial package-lock regeneration pulled in unrelated transitive resolver updates instead of only recording the new Intercom packages required by the feature. * Refactor Intercom setup into a dedicated shell component - Encapsulates Intercom provider, initializer, and fallback logic into a reusable `IntercomShell` component. - Simplifies the main app layout by removing nested provider logic. - Updates the backend auth controller to correctly return 401 for unauthenticated Intercom token requests and aligns the OpenAPI documentation. * Cleanup and format Intercom shell components - Standardize file line endings across Intercom-related files. - Reorder imports and alphabetize mock keys in the test suite. - Refine property formatting for consistency in the Intercom shell component and main app layout. * fixed linting
1 parent 6db0131 commit bd7e4fe

File tree

30 files changed

+799
-43
lines changed

30 files changed

+799
-43
lines changed

.claude/agents/triage.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,18 @@ For actionable issues, produce a plan an engineer can execute immediately:
160160
- [ ] Visual verification: [if UI, what to check in browser]
161161
```
162162

163-
## Step 7 — Deliver Results
163+
## Step 7 — Present Findings & Get Direction
164164

165-
**If triaging a GitHub issue**, post findings directly:
165+
**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.
166+
167+
**If triaging a GitHub issue:**
168+
169+
1. Present your findings to the user (classification, severity, impact, root cause, implementation plan)
170+
2. Thank the reporter for filing the issue
171+
3. Ask the user to review your findings and choose next steps before posting anything to GitHub
172+
4. Only post the triage comment to GitHub after the user confirms the direction
173+
174+
When posting (after user approval):
166175

167176
```bash
168177
gh issue comment <NUMBER> --body "$(cat <<'EOF'
@@ -181,6 +190,9 @@ gh issue comment <NUMBER> --body "$(cat <<'EOF'
181190
182191
### Related
183192
- [Links to related issues, similar patterns found elsewhere]
193+
194+
---
195+
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.
184196
EOF
185197
)"
186198

@@ -213,11 +225,16 @@ After posting the triage comment:
213225

214226
# Final Ask (Required)
215227

216-
Before ending triage, always call `vscode_askQuestions` (askuserquestion) and ask whether they want:
228+
Before ending triage, always call `vscode_askQuestions` (askuserquestion) with the following:
217229

218-
- Deeper analysis on any specific area
219-
- To hand off to `@engineer` immediately
220-
- To adjust severity or priority
221-
- Any other follow-up
230+
1. **Thank the user** for reporting/raising the issue
231+
2. **Present your recommended next steps** as options and ask which direction to go:
232+
- Deeper analysis on any specific area
233+
- Hand off to `@engineer` to implement the proposed plan
234+
- Adjust severity or priority
235+
- Request more information from the reporter
236+
- Any other follow-up
237+
3. **Ask if they have additional context** — "Do you have any additional information or context that might help with this issue?"
238+
4. **Ask what to triage next** — "Is there another issue you'd like me to triage?"
222239

223-
Do not end with findings alone — confirm next action.
240+
Do not end with findings alone — always confirm next action and prompt for the next issue.

src/Exceptionless.Web/ClientApp/package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Exceptionless.Web/ClientApp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"pretty-ms": "^9.3.0",
8989
"runed": "^0.37.1",
9090
"shiki": "^4.0.2",
91+
"svelte-intercom": "^0.0.35",
9192
"svelte-sonner": "^1.1.0",
9293
"svelte-time": "^2.1.0",
9394
"tailwind-merge": "^3.5.0",
@@ -100,4 +101,4 @@
100101
"overrides": {
101102
"storybook": "$storybook"
102103
}
103-
}
104+
}

src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import { useFetchClient } from '@exceptionless/fetchclient';
1+
import { env } from '$env/dynamic/public';
2+
import { getIntercomTokenSessionKey, intercomTokenRefreshIntervalMs } from '$features/intercom/config';
3+
import { ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
4+
import { createQuery } from '@tanstack/svelte-query';
25

36
import type { Login, TokenResult } from './models';
47

58
import { accessToken } from './index.svelte';
69

10+
const queryKeys = {
11+
intercom: (accessToken: null | string) => ['Auth', 'intercom', getIntercomTokenSessionKey(accessToken)] as const
12+
};
13+
714
export async function cancelResetPassword(token: string) {
815
const client = useFetchClient();
916
const response = await client.post(`auth/cancel-reset-password/${token}`, {
@@ -38,6 +45,23 @@ export async function forgotPassword(email: string) {
3845
return await client.get(`auth/forgot-password/${email}`);
3946
}
4047

48+
export function getIntercomTokenQuery() {
49+
return createQuery<TokenResult, ProblemDetails>(() => ({
50+
enabled: () => !!accessToken.current && !!env.PUBLIC_INTERCOM_APPID,
51+
queryFn: async ({ signal }) => {
52+
const client = useFetchClient();
53+
const response = await client.getJSON<TokenResult>('auth/intercom', {
54+
signal
55+
});
56+
57+
return response.data!;
58+
},
59+
queryKey: queryKeys.intercom(accessToken.current),
60+
refetchInterval: intercomTokenRefreshIntervalMs,
61+
staleTime: intercomTokenRefreshIntervalMs
62+
}));
63+
}
64+
4165
/**
4266
* Checks if an email address is already in use.
4367
* @param email The email address to check
@@ -72,7 +96,6 @@ export async function login(email: string, password: string) {
7296
export async function logout() {
7397
const client = useFetchClient();
7498
await client.get('auth/logout', { expectedStatusCodes: [200, 401] });
75-
7699
accessToken.current = '';
77100
}
78101

src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
cancelResetPassword,
1313
changePassword,
1414
forgotPassword,
15+
getIntercomTokenQuery,
1516
isEmailAddressTaken,
1617
login,
1718
logout,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { supportIssuesHref } from '$features/shared/help-links';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { openSupportChat } from './chat';
5+
6+
describe('openSupportChat', () => {
7+
it('opens Intercom messages when the messenger is available', () => {
8+
const intercom = {
9+
showMessages: vi.fn()
10+
};
11+
const openWindow = vi.fn();
12+
13+
openSupportChat(intercom, openWindow);
14+
15+
expect(intercom.showMessages).toHaveBeenCalledOnce();
16+
expect(openWindow).not.toHaveBeenCalled();
17+
});
18+
19+
it('falls back to the support issues page when the messenger is unavailable', () => {
20+
const openWindow = vi.fn();
21+
22+
openSupportChat(undefined, openWindow);
23+
24+
expect(openWindow).toHaveBeenCalledWith(supportIssuesHref, '_blank', 'noopener,noreferrer');
25+
});
26+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { supportIssuesHref } from '$features/shared/help-links';
2+
3+
export interface IntercomMessenger {
4+
showMessages: () => void;
5+
}
6+
7+
export function openSupportChat(intercom: IntercomMessenger | null | undefined, openWindow: typeof globalThis.open = globalThis.open) {
8+
if (intercom) {
9+
intercom.showMessages();
10+
return;
11+
}
12+
13+
openWindow?.(supportIssuesHref, '_blank', 'noopener,noreferrer');
14+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
getIntercomTokenSessionKey,
5+
intercomJwtLifetimeMs,
6+
intercomTokenRefreshIntervalMs,
7+
intercomTokenRefreshLeadTimeMs,
8+
shouldLoadIntercomOrganization
9+
} from './config';
10+
11+
describe('intercom token refresh cadence', () => {
12+
it('refreshes five minutes before a one-hour token expires', () => {
13+
expect(intercomJwtLifetimeMs).toBe(60 * 60 * 1000);
14+
expect(intercomTokenRefreshLeadTimeMs).toBe(5 * 60 * 1000);
15+
expect(intercomTokenRefreshIntervalMs).toBe(55 * 60 * 1000);
16+
expect(intercomTokenRefreshIntervalMs).toBe(intercomJwtLifetimeMs - intercomTokenRefreshLeadTimeMs);
17+
});
18+
19+
it('uses a stable non-secret session key for the current auth token', () => {
20+
expect(getIntercomTokenSessionKey(null)).toBeNull();
21+
expect(getIntercomTokenSessionKey('token-a')).toBe(getIntercomTokenSessionKey('token-a'));
22+
expect(getIntercomTokenSessionKey('token-a')).not.toBe('token-a');
23+
expect(getIntercomTokenSessionKey('token-a')).not.toBe(getIntercomTokenSessionKey('token-b'));
24+
});
25+
26+
it('only loads organization details after Intercom is active for the session', () => {
27+
expect(shouldLoadIntercomOrganization(undefined, true)).toBe(false);
28+
expect(shouldLoadIntercomOrganization('app_123', false)).toBe(false);
29+
expect(shouldLoadIntercomOrganization('app_123', true)).toBe(true);
30+
});
31+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const intercomJwtLifetimeMs = 60 * 60 * 1000;
2+
export const intercomTokenRefreshLeadTimeMs = 5 * 60 * 1000;
3+
export const intercomTokenRefreshIntervalMs = intercomJwtLifetimeMs - intercomTokenRefreshLeadTimeMs;
4+
5+
export function getIntercomTokenSessionKey(accessToken: null | string) {
6+
if (!accessToken) {
7+
return null;
8+
}
9+
10+
let hash = 0;
11+
for (const character of accessToken) {
12+
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
13+
}
14+
15+
return hash.toString(36);
16+
}
17+
18+
export function shouldLoadIntercomOrganization(intercomAppId: null | string | undefined, isIntercomTokenReady: boolean) {
19+
return !!intercomAppId && isIntercomTokenReady;
20+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ViewCurrentUser, ViewOrganization } from '$lib/generated/api';
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { buildIntercomBootOptions } from './context.svelte';
6+
7+
describe('buildIntercomBootOptions', () => {
8+
it('returns undefined when the intercom token is missing', () => {
9+
// Arrange
10+
const user = { id: '67620d1818f5a40d98f3e812' } as ViewCurrentUser;
11+
12+
// Act
13+
const result = buildIntercomBootOptions(user, undefined, undefined);
14+
15+
// Assert
16+
expect(result).toBeUndefined();
17+
});
18+
19+
it('builds boot options from the current user organization and token', () => {
20+
// Arrange
21+
const organizationCreatedUtc = '2024-12-21T01:23:45.000Z';
22+
const user = {
23+
email_address: 'test-user@example.com',
24+
full_name: 'Test User',
25+
id: '67620d1818f5a40d98f3e812'
26+
} as ViewCurrentUser;
27+
const organization = {
28+
billing_price: 29,
29+
created_utc: organizationCreatedUtc,
30+
id: '67620d1818f5a40d98f3e999',
31+
name: 'Acme Corp',
32+
plan_id: 'unlimited'
33+
} as ViewOrganization;
34+
35+
// Act
36+
const result = buildIntercomBootOptions(user, organization, 'signed-intercom-token');
37+
38+
// Assert
39+
expect(result).toEqual({
40+
company: {
41+
createdAt: String(Math.floor(Date.parse(organizationCreatedUtc) / 1000)),
42+
id: organization.id,
43+
monthlySpend: 29,
44+
name: 'Acme Corp',
45+
plan: 'unlimited'
46+
},
47+
createdAt: String(parseInt('67620d18', 16)),
48+
email: 'test-user@example.com',
49+
hideDefaultLauncher: true,
50+
intercomUserJwt: 'signed-intercom-token',
51+
name: 'Test User',
52+
userId: user.id
53+
});
54+
});
55+
});

0 commit comments

Comments
 (0)