Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions packages/manager/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ REACT_APP_APP_ROOT='http://localhost:3000'
# Pendo:
# REACT_APP_PENDO_API_KEY=

# Embedded live chat URLs:
# REACT_APP_CHAT_DEPLOYMENT_URL=
# REACT_APP_CHAT_SCRT2_URL=
# REACT_APP_CHAT_BOOTSTRAP_JS_URL=

# Linode Docs search with Algolia:
# REACT_APP_ALGOLIA_APPLICATION_ID=
# REACT_APP_ALGOLIA_SEARCH_KEY=
Expand Down
58 changes: 58 additions & 0 deletions packages/manager/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,63 @@
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="text/javascript">
let hasLiveChatInitialized = false;

function openLiveChatOnce() {
const enableLiveChat = window.sessionStorage.getItem('EnableLiveChat') === 'true';

if (!enableLiveChat || hasLiveChatInitialized) {
return;
}

hasLiveChatInitialized = true;
window.sessionStorage.removeItem('EnableLiveChat');

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasLiveChatInitialized prevents openLiveChatOnce() from doing anything after the first initialization, even if the user later tries to start another live chat in the same session. If the intent is β€œinit once, launch many”, consider separating initialization from launching (e.g., call embeddedservice_bootstrap.init once, but still allow subsequent triggers to call launchChat() again).

Copilot uses AI. Check for mistakes.
window.addEventListener("message", (event) => {
const { action, data } = event.data;
if (action === "prechatLoaded") {
console.debug("Received from iframe:", data);
const dataMap = {
JWE_Token: "<Token>",
Subject: window.__supportChatSubject || "<Subject>"
};
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.debug("Received from iframe:", data); can leak potentially sensitive pre-chat data into browser logs (and is noisy in production). Please remove this or guard it behind a non-production check.

Copilot uses AI. Check for mistakes.
event.source.postMessage(
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dataMap uses hard-coded placeholders ("<Token>", "<Subject>") and reads window.__supportChatSubject, but there is no corresponding code setting __supportChatSubject in the app. As-is, this will either send placeholder values or undefined into prechat parameters. Please wire these values from the actual ticket title/token source (and avoid hard-coding secrets in HTML).

Copilot uses AI. Check for mistakes.
{ action: "hiddenParameters", data: dataMap },
event.origin
);
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message event handler accepts messages from any origin and blindly trusts event.data (and replies using event.origin). This can allow any window to trigger the prechatLoaded path and potentially receive sensitive hidden parameters. Please validate event.origin against the expected embedded messaging origin, verify event.source is the expected iframe, and defensively check typeof event.data === 'object' before destructuring.

Copilot uses AI. Check for mistakes.
});

window.addEventListener("onEmbeddedMessagingButtonCreated", () => {
embeddedservice_bootstrap.utilAPI.launchChat();
}, { once: true });

embeddedservice_bootstrap.settings.language = "en_US";
embeddedservice_bootstrap.init(
"00DWH000002hLKz",
"Compute_Chat_Support",
"%REACT_APP_CHAT_DEPLOYMENT_URL%",
{
scrt2URL:
"%REACT_APP_CHAT_SCRT2_URL%"
}
);
}

function initEmbeddedMessaging() {
try {
window.addEventListener("manager:enable-live-chat", openLiveChatOnce);
openLiveChatOnce();
} catch (err) {
console.error("Error loading Embedded Messaging: ", err);
}
}
</script>
<script
type="text/javascript"
src="%REACT_APP_CHAT_BOOTSTRAP_JS_URL%"
onload="initEmbeddedMessaging()">
</script>
</body>
</html>
1 change: 1 addition & 0 deletions packages/manager/src/dev-tools/FeatureFlagTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const options: { flag: keyof Flags; label: string }[] = [
{ flag: 'limitsEvolution', label: 'Limits Evolution' },
{ flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' },
{ flag: 'linodeInterfaces', label: 'Linode Interfaces' },
{ flag: 'liveChat', label: 'Live Chat' },
{ flag: 'lkeEnterprise2', label: 'LKE-Enterprise' },
{ flag: 'marketplaceV2', label: 'MarketplaceV2' },
{ flag: 'networkLoadBalancer', label: 'Network Load Balancer' },
Expand Down
1 change: 1 addition & 0 deletions packages/manager/src/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export interface Flags {
linodeCreateBanner: LinodeCreateBanner;
linodeDiskEncryption: boolean;
linodeInterfaces: LinodeInterfacesFlag;
liveChat: boolean;
lkeEnterprise2: LkeEnterpriseFlag;
mainContentBanner: MainContentBanner;
marketplaceAppOverrides: MarketplaceAppOverride[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
Typography,
} from '@linode/ui';
import { reduceAsync, scrollErrorIntoViewV2 } from '@linode/utilities';
import { useLocation } from '@tanstack/react-router';
import { useLocation, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import type { JSX } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { debounce } from 'throttle-debounce';

import { useFlags } from 'src/hooks/useFlags';
import { sendSupportTicketExitEvent } from 'src/utilities/analytics/customEventAnalytics';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
import { storage, supportTicketStorageDefaults } from 'src/utilities/storage';
Expand Down Expand Up @@ -147,8 +148,11 @@
prefilledTicketType,
prefilledTitle,
} = props;
const flags = useFlags();
const liveChat = Boolean(flags.liveChat);

const location = useLocation();
const navigate = useNavigate();
const locationState = location.state as SupportTicketLocationState;

// Collect prefilled data from props or Link parameters.
Expand Down Expand Up @@ -185,7 +189,11 @@
),
entityId: _prefilledEntity?.id ? String(_prefilledEntity.id) : '',
entityInputValue: '',
entityType: _prefilledEntity?.type ?? 'general',
entityType:
_prefilledEntity?.type ??
(valuesFromStorage.entityType === 'general'
? 'none'
: (valuesFromStorage.entityType ?? 'none')),
summary: getInitialValue(newPrefilledTitle, valuesFromStorage.summary),
ticketType: _prefilledTicketType ?? 'general',
},
Expand Down Expand Up @@ -241,7 +249,11 @@
description: clearValues ? '' : valuesFromStorage.description,
entityId: clearValues ? '' : valuesFromStorage.entityId,
entityInputValue: clearValues ? '' : valuesFromStorage.entityInputValue,
entityType: clearValues ? 'general' : valuesFromStorage.entityType,
entityType: clearValues
? 'none'
: valuesFromStorage.entityType === 'general'
? 'none'
: valuesFromStorage.entityType,
Comment on lines +277 to +281
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resetTicket() can reset entityType to valuesFromStorage.entityType without a fallback. If localStorage has older/corrupt data missing entityType, this will reset the form to undefined (violates EntityType and can break rendering/validation). Please default to 'none' when valuesFromStorage.entityType is falsy.

Copilot uses AI. Check for mistakes.
selectedSeverity: clearValues
? undefined
: valuesFromStorage.selectedSeverity,
Expand Down Expand Up @@ -277,6 +289,14 @@
setFiles(newFiles);
};

const handleStartLiveChat = async () => {
window.sessionStorage.setItem('EnableLiveChat', 'true');
window.dispatchEvent(new Event('manager:enable-live-chat'));
props.onClose();
window.setTimeout(() => resetDialog(true), 500);
await navigate({ to: '/support' });
};

/* Reducer passed into reduceAsync (previously Bluebird.reduce) below.
* Unfortunately, this reducer has side effects. Uploads each file and accumulates a list of
* any upload errors. Also tracks loading state of each individual file. */
Expand Down Expand Up @@ -349,6 +369,18 @@
const handleSubmit = form.handleSubmit(async (values) => {
const { onSuccess } = props;

if (values.entityType === 'none') {
form.setError('entityType', {
message: 'Please select a topic.',
});
return;
}

if (liveChat && entityType === 'general') {
await handleStartLiveChat();
return;
}
Comment on lines +434 to +444
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New live-chat behavior (topic required, live-chat start path, hiding severity/description/attachments) isn’t covered by tests. Since this component already has a test file, please add/extend tests to assert: (1) entityType 'none' blocks submit with an error, and (2) selecting General triggers the live-chat handler only after Title validation passes.

Copilot uses AI. Check for mistakes.

const _description = formatDescription(values, ticketType);

// If this is an account limit ticket, we needed the entity type but won't actually send a valid entity selection.
Expand Down Expand Up @@ -459,31 +491,32 @@
/>
)}
/>
{hasSeverityCapability && (
<Controller
control={form.control}
name="selectedSeverity"
render={({ field }) => (
<Autocomplete
autoHighlight
data-qa-ticket-severity
label="Severity"
onChange={(e, severity) =>
field.onChange(
severity !== null ? severity.value : undefined
)
}
options={SEVERITY_OPTIONS}
sx={{ maxWidth: 'initial' }}
textFieldProps={{
tooltipPosition: 'right',
tooltipText: TICKET_SEVERITY_TOOLTIP_TEXT,
}}
value={selectedSeverityOption ?? null}
/>
)}
/>
)}
{hasSeverityCapability &&
!(liveChat && entityType === 'general') && (
<Controller
control={form.control}
name="selectedSeverity"
render={({ field }) => (
<Autocomplete
autoHighlight
data-qa-ticket-severity
label="Severity"
onChange={(e, severity) =>
field.onChange(
severity !== null ? severity.value : undefined
)
}
options={SEVERITY_OPTIONS}
sx={{ maxWidth: 'initial' }}
textFieldProps={{
tooltipPosition: 'right',
tooltipText: TICKET_SEVERITY_TOOLTIP_TEXT,
}}
value={selectedSeverityOption ?? null}
/>
)}
/>
)}
</>
)}
{ticketType === 'smtp' && <SupportTicketSMTPFields />}
Expand All @@ -495,34 +528,38 @@
{(!ticketType || ticketType === 'general') && (
<>
{props.hideProductSelection ? null : (
<SupportTicketProductSelectionFields />
<SupportTicketProductSelectionFields liveChat={liveChat} />
)}
<Box mt={1}>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<TabbedReply
error={fieldState.error?.message}
handleChange={field.onChange}
placeholder={
'Tell us more about the trouble you’re having and any steps you’ve already taken to resolve it.'
}
required
value={description}
{!(liveChat && entityType === 'general') && (
<>
<Box mt={1}>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<TabbedReply
error={fieldState.error?.message}
handleChange={field.onChange}
placeholder={
"Tell us more about the trouble you're having and any steps you've already taken to resolve it."
}
required
value={description}
/>
)}
/>
)}
/>
</Box>
<Accordion
detailProps={{ sx: { p: 0.25 } }}
heading="Formatting Tips"
summaryProps={{ sx: { paddingX: 0.25 } }}
sx={(theme) => ({ mt: `${theme.spacing(0.5)} !important` })} // forcefully disable margin when accordion is expanded
>
<MarkdownReference />
</Accordion>
<AttachFileForm files={files} updateFiles={updateFiles} />
</Box>
<Accordion
detailProps={{ sx: { p: 0.25 } }}
heading="Formatting Tips"
summaryProps={{ sx: { paddingX: 0.25 } }}
sx={(theme) => ({ mt: `${theme.spacing(0.5)} !important` })} // forcefully disable margin when accordion is expanded

Check warning on line 556 in packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Use of theme.spacing() method is deprecated. Use theme.spacingFunction instead. See: https://linode.github.io/manager/development-guide/16-design-tokens.html#spacing Raw Output: {"ruleId":"@linode/cloud-manager/no-mui-theme-spacing","severity":1,"message":"Use of theme.spacing() method is deprecated. Use theme.spacingFunction instead. See: https://linode.github.io/manager/development-guide/16-design-tokens.html#spacing","line":556,"column":46,"nodeType":"CallExpression","messageId":"themeSpacingMethodUsage","endLine":556,"endColumn":64}
>
<MarkdownReference />
</Accordion>
<AttachFileForm files={files} updateFiles={updateFiles} />
</>
)}
{form.formState.errors.root && (
<Notice
data-qa-notice
Expand All @@ -536,9 +573,15 @@
<ActionsPanel
primaryButtonProps={{
'data-testid': 'submit',
label: 'Open Ticket',
label:
liveChat && entityType === 'general'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need additional check for β€œhandleStartLiveChat” based on customer type as well right(basically for premium customers)?

? 'Start a Live Chat'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are we ensuring agents are available for a chat and disabling the chat if no agent available? I know this PR is not a complete one but I believe this will be handled.

: 'Open Ticket',
loading: submitting,
onClick: handleSubmit,
onClick:
liveChat && entityType === 'general'
? handleStartLiveChat
: handleSubmit,
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The live chat CTA uses onClick: handleStartLiveChat which bypasses react-hook-form/Yup validation (e.g., required Title) and also bypasses the new entityType === 'none' guard. Consider wrapping the live-chat path with form.handleSubmit(...) (or form.trigger() + guard) so it validates/sets errors before starting chat.

Copilot uses AI. Check for mistakes.
}}
secondaryButtonProps={{
'data-testid': 'cancel',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
useAllVolumesQuery,
useAllVPCsQuery,
} from '@linode/queries';
import { Autocomplete, FormHelperText, TextField } from '@linode/ui';
import { Autocomplete, Box, Chip, FormHelperText, TextField } from '@linode/ui';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';

Expand All @@ -30,11 +30,12 @@ import type {
import type { APIError } from '@linode/api-v4';

interface Props {
liveChat?: boolean;
ticketType?: TicketType;
}

export const SupportTicketProductSelectionFields = (props: Props) => {
const { ticketType } = props;
const { liveChat, ticketType } = props;
const {
clearErrors,
control,
Expand Down Expand Up @@ -248,22 +249,44 @@ export const SupportTicketProductSelectionFields = (props: Props) => {
<Controller
control={control}
name="entityType"
render={({ field }) => (
render={({ field, fieldState }) => (
<Autocomplete
data-qa-ticket-entity-type
disableClearable
errorText={fieldState.error?.message}
label="What is this regarding?"
onChange={(_e, type) => {
// Don't reset things if the type hasn't changed.
if (type.value === entityType) {
if (type?.value === entityType) {
return;
}
field.onChange(type.value);
field.onChange(type?.value ?? 'none');
setValue('entityId', '');
setValue('entityInputValue', '');
clearErrors('entityId');
}}
options={topicOptions}
placeholder="Select an option?"
renderOption={
liveChat
? (props, option) => (
<li {...props}>
<Box
sx={{
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between',
width: '100%',
}}
>
<span>{option.label}</span>
{option.value === 'general' && (
<Chip label="Live Chat" size="small" />
)}
</Box>
</li>
)
: undefined
}
value={selectedTopic}
/>
)}
Expand Down
Loading