Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
487 changes: 293 additions & 194 deletions src/Exceptionless.Web/ClientApp/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/Exceptionless.Web/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -100,4 +101,4 @@
"overrides": {
"storybook": "$storybook"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { useFetchClient } from '@exceptionless/fetchclient';
import { env } from '$env/dynamic/public';
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: () => ['Auth', 'intercom'] as const
};

export async function cancelResetPassword(token: string) {
const client = useFetchClient();
const response = await client.post(`auth/cancel-reset-password/${token}`, {
Expand Down Expand Up @@ -38,6 +44,21 @@ export async function forgotPassword(email: string) {
return await client.get(`auth/forgot-password/${email}`);
}

export function getIntercomTokenQuery() {
return createQuery<TokenResult, ProblemDetails>(() => ({
enabled: () => !!accessToken.current && !!env.PUBLIC_INTERCOM_APPID,
queryFn: async ({ signal }) => {
const client = useFetchClient();
const response = await client.getJSON<TokenResult>('auth/intercom', {
signal
});

return response.data!;
},
queryKey: queryKeys.intercom()
}));
}

/**
* Checks if an email address is already in use.
* @param email The email address to check
Expand Down Expand Up @@ -72,7 +93,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 = '';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
cancelResetPassword,
changePassword,
forgotPassword,
getIntercomTokenQuery,
isEmailAddressTaken,
login,
logout,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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
};
}

function getUnixTimestampSeconds(dateTime?: string): undefined | string {

Check failure on line 41 in src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.ts

View workflow job for this annotation

GitHub Actions / test-client

Expected "string" to come before "undefined"
if (!dateTime) {
return undefined;
}

const milliseconds = Date.parse(dateTime);
if (Number.isNaN(milliseconds)) {
return undefined;
}

return Math.floor(milliseconds / 1000).toString();
}
/**
* Get the Intercom context from the nearest IntercomInitializer.
* Must be called inside a component wrapped by IntercomInitializer.
*/
export function getIntercom(): IntercomContext | undefined {

Check failure on line 57 in src/Exceptionless.Web/ClientApp/src/lib/features/intercom/context.svelte.ts

View workflow job for this annotation

GitHub Actions / test-client

Expected "getIntercom" (export-function) to come before "getUnixTimestampSeconds" (function)
return getContext<IntercomContext>(INTERCOM_CONTEXT_KEY);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { buildIntercomBootOptions, getIntercom } from './context.svelte';
export { default as IntercomInitializer } from './intercom-initializer.svelte';
export { INTERCOM_CONTEXT_KEY } from './keys';
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script lang="ts" module>
import { useIntercom } from 'svelte-intercom';

export type IntercomContext = ReturnType<typeof useIntercom>;
Comment on lines +1 to +4
</script>

<script lang="ts">
import type { Snippet } from 'svelte';
import type { BootOptions } from 'svelte-intercom';

import { accessToken } from '$features/auth/index.svelte';
import { DocumentVisibility } from '$shared/document-visibility.svelte';
import { useInterval } from 'runed';
import { untrack } from 'svelte';
import { setContext } from 'svelte';

import { INTERCOM_CONTEXT_KEY } from './keys';

interface Props {
bootOptions?: BootOptions;
children: Snippet;
routeKey?: string;
updateIntervalMs?: number;
}

let { bootOptions = undefined, children, routeKey = undefined, updateIntervalMs = 90_000 }: Props = $props();

const intercom = useIntercom();
const visibility = new DocumentVisibility();

setContext<IntercomContext>(INTERCOM_CONTEXT_KEY, intercom);

const interval = useInterval(() => updateIntervalMs, {
callback: () => {
if (bootOptions && visibility.visible) {
intercom.update(bootOptions);
}
},
immediate: false
});

const shouldUpdate = $derived(bootOptions && visibility.visible);

// Sync identity/company data and manage interval when boot options or visibility changes.
$effect(() => {
if (!bootOptions) {
interval.pause();
return;
}

if (visibility.visible) {
interval.resume();
} else {
interval.pause();
}
});

// Sync on route transitions and visibility changes.
$effect(() => {
void routeKey;
if (shouldUpdate) {
untrack(() => intercom.update(bootOptions!));
}
});

// Shutdown when the user logs out.
$effect(() => {
if (!accessToken.current) {
untrack(() => intercom.shutdown());
}
});
</script>

{@render children()}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const INTERCOM_CONTEXT_KEY = Symbol('intercom');
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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';
Expand Down Expand Up @@ -35,22 +36,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<void> {
await goto(resolve('/(app)'));
organization.current = vo.id;
Expand All @@ -75,6 +88,25 @@
</Sidebar.MenuItem>
</Sidebar.Menu>
{:else}
{#if isChatEnabled}
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
onclick={onChatClick}
>
<Help class="size-4" aria-hidden="true" />
<div class="grid flex-1 gap-0.5 text-left">
<span class="text-sm leading-none font-medium">Chat with support</span>
</div>
{#if intercomUnreadCount > 0}
<Sidebar.MenuBadge>{getUnreadCountLabel(intercomUnreadCount)}</Sidebar.MenuBadge>
{/if}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
{/if}
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
Expand Down Expand Up @@ -173,6 +205,15 @@
Help
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{#if isChatEnabled}
<DropdownMenu.Item class="gap-2 p-2" onSelect={onChatClick}>
<Help />
<span class="font-medium">Support</span>
{#if intercomUnreadCount > 0}
<Badge class="ml-auto shrink-0" variant="secondary">{getUnreadCountLabel(intercomUnreadCount)}</Badge>
{/if}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item>
<BookOpen />
<A variant="ghost" href="https://exceptionless.com/docs/" target="_blank" class="w-full">Documentation</A>
Expand Down
Loading
Loading